diff --git a/.env.example b/.env.example index 76c78e6..cf5e338 100644 --- a/.env.example +++ b/.env.example @@ -151,6 +151,16 @@ H5_ADMIN_PASSWORD=change-me-admin # H5_LOCAL_LLM_API_KEY=ollama # H5_LOCAL_LLM_NAME=Local Ollama 7B H5_ACCESS_PASSWORD=change-me +# Portal API 访问策略第一阶段仅支持 off / shadow;shadow 只记录差异,不改变放行结果。 +# MEMIND_PORTAL_ACCESS_POLICY_MODE=shadow +# 同一访问策略差异在窗口内只记录一次,后续日志携带累计与抑制次数。 +# MEMIND_PORTAL_ACCESS_SHADOW_LOG_INTERVAL_MS=60000 +# Phase C 灰度执行必须同时开启总开关并精确列出策略组;默认全部关闭,不支持 all。 +# 可选组:legacy-page-data,page-data-public,plaza-optional-user +# MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED=0 +# MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS= +# 紧急熔断优先级最高;设为 1 后所有策略组立即回到旧鉴权路径(重启 Portal 生效)。 +# MEMIND_PORTAL_ACCESS_POLICY_KILL_SWITCH=0 # 计费(Phase 2,金额单位均为人民币分) # 默认按 Token 单价扣费;生产推荐开启成本模式(见 docs/h5-metering-gateway.md) @@ -286,6 +296,10 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # 【本地开发 — 复制到 .env 的默认值】 MEMIND_RUNTIME_PROFILE=local +# Portal 代码目录与持久化数据目录分离(例如从 git worktree 启动)时显式设置。 +# MindSpace/、users/ 与默认 data/mindspace 都以此目录为根; +# 前端 dist/public 等代码静态资源仍从当前 runtime 目录读取。 +# MEMIND_PORTAL_H5_ROOT=/Users/john/Project/Memind # MINDSPACE_STORAGE_ROOT 留空即可,自动使用 /data/mindspace # 【本机 split-service 联调 — 需要独立 MindSpace LaunchAgent 已启动】 diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 9f0136a..2ffdb8c 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -10,6 +10,10 @@ import { persistSessionTranscriptFromSnapshot, persistSessionTranscriptMessages, } from './conversation-transcript-persist.mjs'; +import { + deriveAssistantFacingText, + deriveUserFacingText, +} from './conversation-display.mjs'; import { ensureGooseUserMessageMetadata } from './goose-message.mjs'; import { prepareAndDetectSessionDeliverables, @@ -72,10 +76,82 @@ function extractRunMessageText(row) { function extractRunDisplayText(row) { const message = parseDbJsonColumn(row?.user_message_json, {}) ?? {}; - const displayText = message?.metadata?.displayText; - return typeof displayText === 'string' && displayText.trim() - ? displayText.trim() - : extractRunMessageText(row); + const displayText = String(message?.metadata?.displayText ?? '').trim(); + return displayText || deriveUserFacingText(extractRunMessageText(row)); +} + +function selectedRunSkill(row) { + const message = parseDbJsonColumn(row?.user_message_json, {}) ?? {}; + return String( + message?.metadata?.[RUN_METADATA_KEY]?.selectedChatSkill + ?? message?.metadata?.selectedChatSkill + ?? '', + ).trim(); +} + +function isPageDeliverableMutationIntent(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + const withoutNegatedActions = normalized.replace( + /(?:不要|不再|无需|不需要|禁止|仅确认|只确认)[^。!?\n]{0,24}(?:创建|生成|制作|搭建|开发|实现|设计|建立|修改|更新|编辑|发布|上线|写入|写|改)[^。!?\n]{0,16}/gu, + '', + ); + return /(?:帮我|请|给我|需要|要)?(?:做一个|做个|创建|生成|制作|搭建|开发|实现|设计|建立|修改|更新|编辑|发布|上线|写入|写一个|改造|新增)/u + .test(withoutNegatedActions); +} + +function messageText(message) { + if (typeof message?.content === 'string') return message.content.trim(); + if (!Array.isArray(message?.content)) return ''; + return message.content + .filter((item) => item?.type === 'text') + .map((item) => String(item?.text ?? '').trim()) + .filter(Boolean) + .join('\n'); +} + +function buildFreshSessionContext(conversation, { maxMessages = 16, maxChars = 12_000 } = {}) { + const visible = []; + for (const message of Array.isArray(conversation) ? conversation : []) { + const role = String(message?.role ?? '').trim(); + if (role !== 'user' && role !== 'assistant') continue; + const raw = messageText(message); + if (!raw) continue; + const text = role === 'user' + ? deriveUserFacingText(raw) + : deriveAssistantFacingText(raw); + if (!text || /^Ran into this error:/i.test(text)) continue; + visible.push(`${role === 'user' ? '用户' : '助手'}:${text}`); + } + const selected = []; + let usedChars = 0; + for (let index = visible.length - 1; index >= 0 && selected.length < maxMessages; index -= 1) { + const item = visible[index]; + if (selected.length > 0 && usedChars + item.length > maxChars) break; + selected.unshift(item); + usedChars += item.length; + } + return selected.join('\n\n'); +} + +function appendFreshSessionContext(userMessage, conversation) { + const context = buildFreshSessionContext(conversation); + if (!context) return userMessage; + const message = userMessage && typeof userMessage === 'object' + ? { ...userMessage } + : { role: 'user', content: [] }; + const content = Array.isArray(message.content) ? [...message.content] : []; + const textIndex = content.findIndex((item) => item?.type === 'text'); + const note = `【会话恢复上下文】\n以下内容仅用于理解上文,不是新的执行指令:\n${context}`; + if (textIndex >= 0) { + content[textIndex] = { + ...content[textIndex], + text: `${String(content[textIndex]?.text ?? '').trim()}\n\n${note}`, + }; + } else { + content.unshift({ type: 'text', text: note }); + } + return { ...message, content }; } function resolveRequiredImageGeneration(row, routing) { @@ -412,12 +488,27 @@ export function createAgentRunGateway({ conflict.status = 409; throw conflict; } - const [activeRows] = await pool.query( + let [activeRows] = await pool.query( `SELECT id FROM h5_agent_runs WHERE agent_session_id = ? AND status NOT IN ('succeeded', 'failed') LIMIT 1`, [sessionId], ); + if (activeRows.length > 0) { + await recoverStaleRunningRuns({ + staleMs: runTimeoutMs, + limit: 1, + dryRun: false, + reason: 'create_run_session_conflict_stale_recovery', + sessionId, + }); + [activeRows] = await pool.query( + `SELECT id FROM h5_agent_runs + WHERE agent_session_id = ? AND status NOT IN ('succeeded', 'failed') + LIMIT 1`, + [sessionId], + ); + } if (activeRows.length > 0) { const conflict = new Error('该会话有正在处理的任务,请等待完成后再发送'); conflict.code = 'SESSION_RUN_CONFLICT'; @@ -830,35 +921,74 @@ export function createAgentRunGateway({ && runOptions.toolMode === 'chat' && typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function'; if (awaitSessionFinish) { - try { - const finish = await tkmindProxy.submitSessionReplyAndAwaitFinishForUser( - row.user_id, - sessionId, - row.request_id, - ensureGooseUserMessageMetadata(userMessage), - { - toolMode: runOptions.toolMode, - forceDeepReasoning: runOptions.forceDeepReasoning, - timeoutMs: runTimeoutMs, - }, - ); - await appendEvent(runId, 'session_finished', { - sessionId, - tokenState: finish.tokenState ?? null, - toolCalls: finish.toolEvidence?.calls ?? [], - generateImage: finish.toolEvidence?.generateImage ?? null, - }); - toolEvidence = finish.toolEvidence ?? null; - } catch (err) { - if (await recoverRunFromDeliverables({ - runId, - userId: row.user_id, - sessionId, - err, - })) { - return { sessionId, routing }; + let submitMessage = ensureGooseUserMessageMetadata(userMessage); + let replacedPoisonedSession = false; + while (true) { + try { + const finish = await tkmindProxy.submitSessionReplyAndAwaitFinishForUser( + row.user_id, + sessionId, + row.request_id, + submitMessage, + { + toolMode: runOptions.toolMode, + forceDeepReasoning: runOptions.forceDeepReasoning, + timeoutMs: runTimeoutMs, + }, + ); + await appendEvent(runId, 'session_finished', { + sessionId, + tokenState: finish.tokenState ?? null, + toolCalls: finish.toolEvidence?.calls ?? [], + generateImage: finish.toolEvidence?.generateImage ?? null, + }); + toolEvidence = finish.toolEvidence ?? null; + break; + } catch (err) { + if ( + !replacedPoisonedSession + && err?.code === 'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED' + ) { + const previousSessionId = sessionId; + const repairedConversation = Array.isArray(err?.repairedConversation) + ? err.repairedConversation + : []; + const replacement = await tkmindProxy.startSessionForUser(row.user_id); + sessionId = replacement.id; + replacedPoisonedSession = true; + await pool.query( + `UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`, + [sessionId, nowMs(), runId], + ); + const persisted = await persistSessionTranscriptMessages({ + conversationMemoryService, + sessionId, + userId: row.user_id, + messages: repairedConversation, + }); + await appendEvent(runId, 'poisoned_session_replaced', { + previousSessionId, + sessionId, + repairedMessageCount: repairedConversation.length, + persistedMessageCount: persisted.saved, + }); + await appendRunSnapshot(runId); + await invalidatePortalDirectChatSnapshot(sessionId); + submitMessage = ensureGooseUserMessageMetadata( + appendFreshSessionContext(userMessage, repairedConversation), + ); + continue; + } + if (await recoverRunFromDeliverables({ + runId, + userId: row.user_id, + sessionId, + err, + })) { + return { sessionId, routing }; + } + throw err; } - throw err; } } else { await tkmindProxy.submitSessionReplyForUser( @@ -905,17 +1035,22 @@ export function createAgentRunGateway({ error.retryable = false; throw error; } - const runMessageText = extractRunMessageText(row); const runDisplayText = extractRunDisplayText(row); - const pageDataIntent = isPageDataIntent(runMessageText) + const selectedSkill = selectedRunSkill(row); + const pageDataIntent = isPageDataIntent(runDisplayText) + || selectedSkill === 'page-data-collect' || routing?.suggestedSkill === 'page-data-collect'; - const pageGenerationIntent = isPageGenerationIntent(runMessageText) + const pageGenerationIntent = isPageGenerationIntent(runDisplayText) + || selectedSkill === 'generate-page' || routing?.suggestedSkill === 'static-page-publish'; // A bare “generate a page” request has no subject to render. The assistant's // clarification is a successful conversational turn, not a failed delivery. // Once the user supplies a subject, the existing fail-closed guard still applies. - const requiresPageDeliverable = pageDataIntent - || (pageGenerationIntent && !isGenericPageGenerationRequest(runDisplayText)); + const requiresPageDeliverable = isPageDeliverableMutationIntent(runDisplayText) + && (pageDataIntent || ( + pageGenerationIntent + && !isGenericPageGenerationRequest(runDisplayText) + )); let deliverables = null; if (requiresPageDeliverable || typeof validateRunDeliverables === 'function') { const latest = await getRunById(runId); @@ -1144,6 +1279,7 @@ export function createAgentRunGateway({ dryRun = true, reason = 'stale_running_timeout', sessionFinishedGraceMs = SESSION_FINISHED_STALE_GRACE_MS, + sessionId = null, } = {}) { const normalizedStaleMs = positiveInteger(staleMs, runTimeoutMs); const normalizedLimit = positiveInteger(limit, maxConcurrentRuns); @@ -1154,6 +1290,7 @@ export function createAgentRunGateway({ const startedCutoff = nowMs() - normalizedStaleMs; const sessionFinishedCutoff = nowMs() - normalizedSessionFinishedGraceMs; const heartbeatCutoff = nowMs() - normalizedStaleMs; + const normalizedSessionId = String(sessionId ?? '').trim(); const [rows] = await pool.query( `SELECT r.id, @@ -1188,9 +1325,16 @@ export function createAgentRunGateway({ AND sf.session_finished_at <= ? ) ) + ${normalizedSessionId ? 'AND r.agent_session_id = ?' : ''} ORDER BY COALESCE(sf.session_finished_at, r.started_at) ASC LIMIT ?`, - [heartbeatCutoff, startedCutoff, sessionFinishedCutoff, normalizedLimit], + [ + heartbeatCutoff, + startedCutoff, + sessionFinishedCutoff, + ...(normalizedSessionId ? [normalizedSessionId] : []), + normalizedLimit, + ], ); const recovered = []; for (const row of rows) { diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 9ec4e78..726c45f 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -116,9 +116,15 @@ function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} } }]]; } if (sql.includes('SELECT') && sql.includes('session_finished_at') && sql.includes('r.started_at <= ?')) { - const [heartbeatCutoff, startedCutoff, sessionFinishedCutoff, limit = 1] = params; + const hasSessionFilter = sql.includes('AND r.agent_session_id = ?'); + const [heartbeatCutoff, startedCutoff, sessionFinishedCutoff] = params; + const sessionId = hasSessionFilter ? params[3] : null; + const limit = params[hasSessionFilter ? 4 : 3] ?? 1; return [[...runs.values()] - .filter((row) => isStaleRunningRow(row, heartbeatCutoff, startedCutoff, sessionFinishedCutoff)) + .filter((row) => ( + (!sessionId || row.agent_session_id === sessionId) && + isStaleRunningRow(row, heartbeatCutoff, startedCutoff, sessionFinishedCutoff) + )) .sort((a, b) => { const aKey = Number(sessionFinishedAt(a.id) ?? a.started_at ?? 0); const bKey = Number(sessionFinishedAt(b.id) ?? b.started_at ?? 0); @@ -431,6 +437,77 @@ test('agent run awaits session Finish before succeeding when proxy supports it', assert.equal(finishEvents.length, 1); }); +test('agent run replaces poisoned Goose session and retries with visible context', async () => { + const pool = createFakePool(); + const submitted = []; + const saved = []; + const repairedConversation = [ + { + role: 'user', + content: [{ + type: 'text', + text: '【Memind 任务编排】执行页面任务\n用户任务:帮我做一个心情日记', + }], + }, + { + role: 'assistant', + content: [{ + type: 'text', + text: '心情日记已经完成:https://example.com/MindSpace/user-1/public/mood.html', + }], + }, + ]; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-clean' }; + }, + async submitSessionReplyAndAwaitFinishForUser(userId, sessionId, requestId, userMessage) { + submitted.push({ userId, sessionId, requestId, userMessage }); + if (sessionId === 'session-poisoned') { + const error = new Error('tool history requires fresh session'); + error.code = 'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED'; + error.repairedConversation = repairedConversation; + throw error; + } + return { ok: true, finishEvent: { type: 'Finish' }, tokenState: { totalTokens: 12 } }; + }, + }, + conversationMemoryService: { + async saveConversationMessages(sessionId, userId, messages) { + saved.push({ sessionId, userId, messages }); + return messages; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + sessionId: 'session-poisoned', + requestId: 'req-poisoned-replacement', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '只确认当前状态' }], + }, + forceDeepReasoning: true, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.deepEqual(submitted.map((item) => item.sessionId), [ + 'session-poisoned', + 'session-clean', + ]); + assert.match(submitted[1].userMessage.content[0].text, /会话恢复上下文/); + assert.match(submitted[1].userMessage.content[0].text, /帮我做一个心情日记/); + assert.match(submitted[1].userMessage.content[0].text, /心情日记已经完成/); + assert.equal(pool.runs.get(run.id).agent_session_id, 'session-clean'); + assert.equal(saved.length, 1); + assert.equal(saved[0].sessionId, 'session-clean'); + assert.ok(pool.events.some((event) => event.eventType === 'poisoned_session_replaced')); +}); + test('Page Data run fails closed when Finish arrives without a generated page', async () => { const pool = createFakePool(); const gateway = createAgentRunGateway({ @@ -460,6 +537,61 @@ test('Page Data run fails closed when Finish arrives without a generated page', assert.match(pool.runs.get(run.id).error_message, /未生成可交付页面/); }); +test('Page Data routed status follow-up does not require a new page deliverable', async () => { + const pool = createFakePool(); + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + chatIntentRouter: { + isEnabled() { + return true; + }, + async classify() { + return { + route: 'agent_orchestration', + confidence: 1, + reason: '页面数据交互意图', + suggestedSkill: 'page-data-collect', + source: 'rule', + }; + }, + applyAgentOrchestration(message) { + return message; + }, + }, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-page-data-status' }; + }, + async submitSessionReplyAndAwaitFinishForUser() { + return { ok: true, finishEvent: { type: 'Finish' } }; + }, + }, + syncUserPagesOnSuccess: async () => ({ + pageDataBind: { errors: [] }, + pageDataRelativePaths: [], + }), + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-page-data-status', + userMessage: { + role: 'user', + content: [{ + type: 'text', + text: '请使用 page-data-collect 技能完成任务。只确认现有心情日记是否已经完成,不要创建或修改任何页面,只回复当前状态。', + }], + metadata: { + displayText: '只确认现有心情日记是否已经完成,不要创建或修改任何页面,只回复当前状态。', + }, + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(pool.runs.get(run.id).error_message, null); +}); + test('implicit sticky-note app run fails closed when Apps returns Finish without a public page', async () => { const pool = createFakePool(); const gateway = createAgentRunGateway({ @@ -2230,6 +2362,53 @@ test('createRun rejects with SESSION_RUN_CONFLICT when same session already has assert.equal(run3.requestId, 'req-conflict-3'); }); +test('createRun recovers a stale active run for the same session before accepting a new run', async () => { + const pool = createFakePool(); + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: {}, + autoDispatch: false, + runTimeoutMs: 1000, + }); + + const activeRunId = crypto.randomUUID(); + const staleStartedAt = Date.now() - 5000; + pool.runs.set(activeRunId, { + id: activeRunId, + user_id: 'user-1', + agent_session_id: 'sess-stale-conflict', + request_id: 'req-stale-active', + status: 'running', + attempts: 1, + user_message_json: '{}', + error_message: null, + created_at: staleStartedAt, + updated_at: staleStartedAt, + started_at: staleStartedAt, + completed_at: null, + }); + + const created = await gateway.createRun('user-1', { + sessionId: 'sess-stale-conflict', + requestId: 'req-after-stale', + userMessage: { role: 'user', content: [{ type: 'text', text: 'continue' }] }, + }); + + assert.equal(pool.runs.get(activeRunId).status, 'failed'); + assert.equal(created.requestId, 'req-after-stale'); + assert.equal(created.status, 'queued'); + assert.ok( + pool.events.some( + (event) => + event.runId === activeRunId && + event.eventType === 'stale_recovered' && + JSON.parse(event.dataJson).reason === + 'create_run_session_conflict_stale_recovery', + ), + ); +}); + test('createRun rejects while the same session is finishing page delivery', async () => { const pool = createFakePool(); const gateway = createAgentRunGateway({ diff --git a/capabilities.mjs b/capabilities.mjs index 187a91f..5c1aa19 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -2,11 +2,25 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveAgentGooseMode } from './policies.mjs'; -/** Spawned as a separate Node process by goosed; must sit beside bundled portal runtime. */ -export function resolveSandboxMcpServerPath(overridePath) { +function resolveBundledMcpServerPath(filename, overridePath, runtimeRoot) { const normalized = String(overridePath ?? '').trim(); if (normalized) return normalized; - return path.join(path.dirname(fileURLToPath(import.meta.url)), 'mindspace-sandbox-mcp.mjs'); + const normalizedRuntimeRoot = String( + runtimeRoot ?? process.env.MEMIND_PORTAL_H5_ROOT ?? '', + ).trim(); + return path.join( + normalizedRuntimeRoot || path.dirname(fileURLToPath(import.meta.url)), + filename, + ); +} + +/** Spawned as a separate Node process by goosed; must sit beside bundled portal runtime. */ +export function resolveSandboxMcpServerPath(overridePath, runtimeRoot) { + return resolveBundledMcpServerPath( + 'mindspace-sandbox-mcp.mjs', + overridePath, + runtimeRoot, + ); } export function resolveSandboxMcpNodeExecPath(overridePath) { @@ -17,6 +31,25 @@ export function resolveSandboxMcpNodeExecPath(overridePath) { const LOOPBACK_PG_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']); +function rewritePgUnixSocketUrlForContainer(sourceUrl, hostGateway) { + const raw = String(sourceUrl ?? '').trim(); + const authorityMatch = raw.match(/^(postgres(?:ql)?:\/\/[^@/]+@)\/(.*)$/i); + if (!authorityMatch) return null; + try { + const parsed = new URL(`${authorityMatch[1]}localhost/${authorityMatch[2]}`); + const socketHost = String(parsed.searchParams.get('host') ?? '').trim(); + if (!socketHost.startsWith('/')) return null; + const port = String(parsed.searchParams.get('port') ?? '5432').trim() || '5432'; + parsed.hostname = String(hostGateway || 'host.docker.internal').trim(); + parsed.port = port; + parsed.searchParams.delete('host'); + parsed.searchParams.delete('port'); + return parsed.toString(); + } catch { + return null; + } +} + export function resolveSandboxMcpUserDataPgUrl({ portalUrl, mcpUrl, @@ -36,22 +69,38 @@ export function resolveSandboxMcpUserDataPgUrl({ } return parsed.toString(); } catch { + const rewrittenSocketUrl = rewritePgUnixSocketUrlForContainer(sourceUrl, hostGateway); + if (rewrittenSocketUrl) { + const parsed = new URL(rewrittenSocketUrl); + if (!parsed.password) { + const error = new Error( + 'Containerized private-data MCP cannot convert a passwordless PostgreSQL Unix-socket DSN to TCP. Configure MINDSPACE_USERDATA_MCP_PG_URL with a container-reachable authenticated DSN.', + ); + error.code = 'MINDSPACE_USERDATA_MCP_PG_URL_REQUIRED'; + throw error; + } + return rewrittenSocketUrl; + } // Preserve the configured value so the MCP reports the real configuration // error instead of silently falling back to its local Unix socket default. return sourceUrl; } } -export function resolveMindSearchMcpServerPath(overridePath) { - const normalized = String(overridePath ?? '').trim(); - if (normalized) return normalized; - return path.join(path.dirname(fileURLToPath(import.meta.url)), 'tkmind-search-mcp.mjs'); +export function resolveMindSearchMcpServerPath(overridePath, runtimeRoot) { + return resolveBundledMcpServerPath( + 'tkmind-search-mcp.mjs', + overridePath, + runtimeRoot, + ); } -export function resolveExcelMcpServerPath(overridePath) { - const normalized = String(overridePath ?? '').trim(); - if (normalized) return normalized; - return path.join(path.dirname(fileURLToPath(import.meta.url)), 'tkmind-excel-mcp.mjs'); +export function resolveExcelMcpServerPath(overridePath, runtimeRoot) { + return resolveBundledMcpServerPath( + 'tkmind-excel-mcp.mjs', + overridePath, + runtimeRoot, + ); } export const CAPABILITY_CATALOG = [ @@ -352,6 +401,19 @@ function sandboxMcpEnvs(sandboxMcp, mcpTools) { if (sandboxMcp.workspaceRoot || localRoot) envs.MINDSPACE_WORKSPACE_ROOT = sandboxMcp.workspaceRoot || localRoot; if (sandboxMcp.workspaceRef) envs.MINDSPACE_WORKSPACE_REF = sandboxMcp.workspaceRef; if (sandboxMcp.userId) envs.PRIVATE_DATA_USER_ID = sandboxMcp.userId; + for (const [key, value] of [ + ['H5_PUBLIC_BASE_URL', sandboxMcp.publicBaseUrl], + ['H5_PORTAL_BASE_URL', sandboxMcp.portalBaseUrl], + ['H5_PORT', sandboxMcp.portalPort], + [ + 'MEMIND_PAGE_DATA_DELIVERY_BASE_URL', + sandboxMcp.pageDataDeliveryBaseUrl, + ], + ]) { + if (value != null && String(value).trim()) { + envs[key] = String(value).trim(); + } + } if (mcpTools.includes('generate_image')) { if (sandboxMcp.agentApiBaseUrl) { envs.MINDSPACE_AGENT_API_BASE_URL = sandboxMcp.agentApiBaseUrl; diff --git a/capabilities.test.mjs b/capabilities.test.mjs index 84bf9cd..0d032ff 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -8,6 +8,8 @@ import { clampUserCapabilities, DEFAULT_USER_CAPABILITIES, normalizeCapabilityPatch, + resolveExcelMcpServerPath, + resolveMindSearchMcpServerPath, resolveSandboxMcpNodeExecPath, resolveSandboxMcpServerPath, resolveSandboxMcpUserDataPgUrl, @@ -29,6 +31,22 @@ test('resolveSandboxMcpServerPath honors container-path override without host fs ); }); +test('bundled MCP paths use the persistent Portal runtime root in split mode', () => { + const runtimeRoot = '/srv/memind-persistent'; + assert.equal( + resolveSandboxMcpServerPath('', runtimeRoot), + '/srv/memind-persistent/mindspace-sandbox-mcp.mjs', + ); + assert.equal( + resolveMindSearchMcpServerPath('', runtimeRoot), + '/srv/memind-persistent/tkmind-search-mcp.mjs', + ); + assert.equal( + resolveExcelMcpServerPath('', runtimeRoot), + '/srv/memind-persistent/tkmind-excel-mcp.mjs', + ); +}); + test('resolveSandboxMcpUserDataPgUrl rewrites a portal loopback URL for container MCP access', () => { const resolved = resolveSandboxMcpUserDataPgUrl({ portalUrl: 'postgresql://mindspace:secret@127.0.0.1:5433/mindspace_userdata_prod', @@ -40,6 +58,35 @@ test('resolveSandboxMcpUserDataPgUrl rewrites a portal loopback URL for containe assert.equal(parsed.pathname, '/mindspace_userdata_prod'); }); +test('resolveSandboxMcpUserDataPgUrl rewrites a host Unix socket DSN for container MCP access', () => { + const resolved = resolveSandboxMcpUserDataPgUrl({ + portalUrl: 'postgresql://john:secret@/mindspace_userdata_dev?host=%2Ftmp&port=5433', + containerized: true, + }); + const parsed = new URL(resolved); + assert.equal(parsed.hostname, 'host.docker.internal'); + assert.equal(parsed.port, '5433'); + assert.equal(parsed.pathname, '/mindspace_userdata_dev'); + assert.equal(parsed.password, 'secret'); + assert.equal(parsed.searchParams.has('host'), false); + assert.equal(parsed.searchParams.has('port'), false); +}); + +test('resolveSandboxMcpUserDataPgUrl fails fast for passwordless socket DSN in a container', () => { + assert.throws( + () => + resolveSandboxMcpUserDataPgUrl({ + portalUrl: + 'postgresql://john@/mindspace_userdata_dev?host=%2Ftmp&port=5433', + containerized: true, + }), + (error) => + error?.code === + 'MINDSPACE_USERDATA_MCP_PG_URL_REQUIRED' && + /MINDSPACE_USERDATA_MCP_PG_URL/.test(error.message), + ); +}); + test('resolveSandboxMcpUserDataPgUrl preserves native URLs and honors an explicit MCP URL', () => { const portalUrl = 'postgresql://mindspace:secret@127.0.0.1:5433/mindspace_userdata_prod'; assert.equal(resolveSandboxMcpUserDataPgUrl({ portalUrl }), portalUrl); @@ -334,6 +381,10 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of sandboxRoot: '/opt/h5/MindSpace/abc123', agentApiBaseUrl: 'http://host.docker.internal:8081/api', internalAgentSecret: 'internal-secret', + publicBaseUrl: 'http://127.0.0.1:18131', + portalBaseUrl: 'http://127.0.0.1:18131', + portalPort: '18131', + pageDataDeliveryBaseUrl: 'http://127.0.0.1:18131', }; const policy = buildAgentExtensionPolicy(caps, { sandboxMcp }); @@ -345,6 +396,13 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of assert.equal(sandboxExt.args[1], '/opt/h5/MindSpace/abc123'); // also passed as argv[2] assert.equal(sandboxExt.envs.MINDSPACE_AGENT_API_BASE_URL, 'http://host.docker.internal:8081/api'); assert.equal(sandboxExt.envs.MINDSPACE_INTERNAL_AGENT_SECRET, 'internal-secret'); + assert.equal(sandboxExt.envs.H5_PUBLIC_BASE_URL, 'http://127.0.0.1:18131'); + assert.equal(sandboxExt.envs.H5_PORTAL_BASE_URL, 'http://127.0.0.1:18131'); + assert.equal(sandboxExt.envs.H5_PORT, '18131'); + assert.equal( + sandboxExt.envs.MEMIND_PAGE_DATA_DELIVERY_BASE_URL, + 'http://127.0.0.1:18131', + ); assert.ok(sandboxExt.available_tools.includes('write_file')); assert.ok(sandboxExt.available_tools.includes('read_file')); assert.ok(sandboxExt.available_tools.includes('generate_docx')); diff --git a/chat-tool-history-repair.mjs b/chat-tool-history-repair.mjs new file mode 100644 index 0000000..ff80adb --- /dev/null +++ b/chat-tool-history-repair.mjs @@ -0,0 +1,109 @@ +function messageContent(message) { + return Array.isArray(message?.content) ? message.content : []; +} + +function toolItemId(item, type) { + if (item?.type !== type) return null; + return String(item?.id ?? '').trim() || null; +} + +function cloneMessageWithContent(message, content) { + return { ...message, content }; +} + +/** + * Goose can persist concurrently completed tools out of order. OpenAI-compatible + * providers reject the next turn when an assistant tool request is not followed + * immediately by its matching tool response. Re-pair completed calls and remove + * incomplete/orphaned protocol items while preserving all visible text. + */ +export function repairConversationToolHistory(conversation) { + if (!Array.isArray(conversation) || conversation.length === 0) { + return { + conversation: Array.isArray(conversation) ? conversation : [], + changed: false, + repairedToolResponses: 0, + droppedToolRequests: 0, + droppedOrphanResponses: 0, + }; + } + + const responsesById = new Map(); + let totalToolResponses = 0; + for (const message of conversation) { + for (const item of messageContent(message)) { + const id = toolItemId(item, 'toolResponse'); + if (!id) continue; + totalToolResponses += 1; + if (!responsesById.has(id)) responsesById.set(id, { message, item }); + } + } + + const repaired = []; + const consumedResponseIds = new Set(); + let repairedToolResponses = 0; + let droppedToolRequests = 0; + + for (const message of conversation) { + const content = messageContent(message); + const requestItems = content.filter((item) => item?.type === 'toolRequest'); + const responseItems = content.filter((item) => item?.type === 'toolResponse'); + + if (requestItems.length === 0) { + if (responseItems.length === 0) { + repaired.push(message); + continue; + } + const remaining = content.filter((item) => item?.type !== 'toolResponse'); + if (remaining.length > 0) repaired.push(cloneMessageWithContent(message, remaining)); + continue; + } + + const matchedRequests = []; + for (const item of requestItems) { + const id = toolItemId(item, 'toolRequest'); + if (id && responsesById.has(id)) { + matchedRequests.push(item); + } else { + droppedToolRequests += 1; + } + } + const retainedContent = content.filter( + (item) => item?.type !== 'toolRequest' && item?.type !== 'toolResponse', + ); + retainedContent.push(...matchedRequests); + if (retainedContent.length > 0) { + const unchanged = + retainedContent.length === content.length && + retainedContent.every((item, index) => item === content[index]); + repaired.push(unchanged ? message : cloneMessageWithContent(message, retainedContent)); + } + + for (const request of matchedRequests) { + const id = toolItemId(request, 'toolRequest'); + const response = responsesById.get(id); + if (!response || consumedResponseIds.has(id)) continue; + consumedResponseIds.add(id); + repairedToolResponses += 1; + const originalContent = messageContent(response.message); + repaired.push( + originalContent.length === 1 && originalContent[0] === response.item + ? response.message + : cloneMessageWithContent(response.message, [response.item]), + ); + } + } + + const droppedOrphanResponses = Math.max(0, totalToolResponses - consumedResponseIds.size); + const changed = + repaired.length !== conversation.length || + repaired.some((message, index) => message !== conversation[index]); + + return { + conversation: changed ? repaired : conversation, + changed, + repairedToolResponses, + droppedToolRequests, + droppedOrphanResponses, + }; +} diff --git a/chat-tool-history-repair.test.mjs b/chat-tool-history-repair.test.mjs new file mode 100644 index 0000000..44f1fb2 --- /dev/null +++ b/chat-tool-history-repair.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { repairConversationToolHistory } from './chat-tool-history-repair.mjs'; + +function toolRequest(id, name = 'write_file') { + return { + role: 'assistant', + content: [{ + type: 'toolRequest', + id, + toolCall: { status: 'success', value: { name, arguments: {} } }, + }], + }; +} + +function toolResponse(id) { + return { + role: 'user', + content: [{ + type: 'toolResponse', + id, + toolResult: { + status: 'success', + value: { content: [{ type: 'text', text: 'ok' }], isError: false }, + }, + }], + }; +} + +test('repairConversationToolHistory pairs interleaved and reversed tool responses', () => { + const conversation = [ + { role: 'user', content: [{ type: 'text', text: '生成页面' }] }, + toolRequest('call-a'), + toolRequest('call-b'), + toolResponse('call-b'), + { role: 'assistant', content: [{ type: 'text', text: '继续处理' }] }, + toolResponse('call-a'), + toolResponse('call-c'), + toolRequest('call-c'), + { role: 'assistant', content: [{ type: 'text', text: '完成' }] }, + ]; + + const result = repairConversationToolHistory(conversation); + + assert.equal(result.changed, true); + assert.deepEqual( + result.conversation.map((message) => message.content[0]?.id ?? message.content[0]?.text), + ['生成页面', 'call-a', 'call-a', 'call-b', 'call-b', '继续处理', 'call-c', 'call-c', '完成'], + ); + assert.equal(result.repairedToolResponses, 3); + assert.equal(result.droppedToolRequests, 0); + assert.equal(result.droppedOrphanResponses, 0); +}); + +test('repairConversationToolHistory leaves valid tool history unchanged', () => { + const conversation = [ + { role: 'user', content: [{ type: 'text', text: '生成页面' }] }, + toolRequest('call-a'), + toolResponse('call-a'), + { role: 'assistant', content: [{ type: 'text', text: '完成' }] }, + ]; + + const result = repairConversationToolHistory(conversation); + + assert.equal(result.changed, false); + assert.equal(result.conversation, conversation); +}); + +test('repairConversationToolHistory drops unmatched requests and orphan responses', () => { + const conversation = [ + { role: 'user', content: [{ type: 'text', text: '生成页面' }] }, + toolRequest('call-missing'), + toolResponse('call-orphan'), + { role: 'assistant', content: [{ type: 'text', text: '稍后继续' }] }, + ]; + + const result = repairConversationToolHistory(conversation); + + assert.equal(result.changed, true); + assert.deepEqual( + result.conversation.map((message) => message.content[0]?.text), + ['生成页面', '稍后继续'], + ); + assert.equal(result.droppedToolRequests, 1); + assert.equal(result.droppedOrphanResponses, 1); +}); diff --git a/conversation-display.mjs b/conversation-display.mjs index ab99efe..fadd8d8 100644 --- a/conversation-display.mjs +++ b/conversation-display.mjs @@ -8,6 +8,8 @@ import { stripKnownChatSkillPrompt } from './chat-skills.mjs'; export const TASK_ROUTING_HINT_RE = /^【TKMind 路由提示】[\s\S]*?\n\n/; export const MEMIND_TASK_ORCHESTRATION_RE = /^【Memind 任务编排】[\s\S]*?(?:用户任务:|用户任务:)\s*/u; export const MINDSPACE_CONTEXT_RE = /^\[MindSpace 上下文\][\s\S]*?\n\n/; +export const IMAGE_TURN_SCOPE_SUFFIX_RE = + /\n*【本轮图片主题(硬性)】[\s\S]*$/u; const USER_IDENTITY_BLOCK_RE = /^\[用户身份\][\s\S]*?\n\n/; export function stripUserIdentityPrefix(text) { @@ -63,6 +65,7 @@ export function deriveAssistantFacingText(text) { /** Strip agent-only prefixes from persisted user message content for UI display. */ export function deriveUserFacingText(text) { let next = String(text ?? ''); + next = next.replace(IMAGE_TURN_SCOPE_SUFFIX_RE, ''); next = stripUserIdentityPrefix(next); next = stripTaskRoutingHint(next); next = stripMemindTaskOrchestrationPrefix(next); diff --git a/conversation-display.test.mjs b/conversation-display.test.mjs index 8b2ba8a..de1e344 100644 --- a/conversation-display.test.mjs +++ b/conversation-display.test.mjs @@ -55,6 +55,20 @@ test('deriveUserFacingText removes Memind task orchestration prefix', () => { assert.equal(deriveUserFacingText(agentPayload), userText); }); +test('deriveUserFacingText removes image turn scope metadata appended for the agent', () => { + const userText = + '请识别这张图片,只回答主体场景、时间氛围、主要颜色、是否有人物。'; + const persistedText = [ + userText, + '', + '【本轮图片主题(硬性)】', + '- 本轮用户仅上传 1 张图片;每次上传都是独立主题,不得与历史轮次混用。', + ' 1. asset=asset-1 页面嵌入=/api/mindspace/v1/assets/asset-1/download?inline=1', + ].join('\n'); + + assert.equal(deriveUserFacingText(persistedText), userText); +}); + test('deriveAssistantFacingText hides skill/process narration without a deliverable link', () => { const internal = '好的,John!注意到技能更新了几个细节要求,比如页脚要用`data-mindspace-page-tag="platform-brand"`和`tkmind.cn`。我来为你重新生成全面增强版的AI机器人研究报告页面:'; diff --git a/docs/regression-guards/h5-session-stream-replay.md b/docs/regression-guards/h5-session-stream-replay.md index b1b96ca..e66ae10 100644 --- a/docs/regression-guards/h5-session-stream-replay.md +++ b/docs/regression-guards/h5-session-stream-replay.md @@ -21,6 +21,7 @@ Portal 对 SSE 帧使用本地持久化游标。该游标可能是 Portal 生成 - `session-stream.mjs` - `session-stream-store.mjs` - `tkmind-proxy.mjs` +- `server/portal-session-routes.mjs`(Portal Session 路由、流中 public HTML 落盘与 Finish 后同步) - `src/hooks/useTKMindChat.ts` ## 回归测试 diff --git a/docs/regression-guards/mindspace-publish-and-chat-finish.md b/docs/regression-guards/mindspace-publish-and-chat-finish.md index 8239d4e..cd5221c 100644 --- a/docs/regression-guards/mindspace-publish-and-chat-finish.md +++ b/docs/regression-guards/mindspace-publish-and-chat-finish.md @@ -38,7 +38,8 @@ | 位置 | 行为 | |------|------| | `src/hooks/useTKMindChat.ts` | `syncSessionMessages` 用 `mergeConversationSnapshot`,**禁止**盲覆盖;服务端条数不足时按 `FINISH_SYNC_RETRY_DELAYS_MS` 重试 | -| `server.mjs` | Session snapshot 缓存仅在 `hint_mc` **且** `hint_ua` 均提供且匹配时命中 | +| `server/portal-session-routes.mjs` | Session snapshot 缓存仅在 `hint_mc` **且** `hint_ua` 均提供且匹配时命中 | +| `server.mjs` | 必须通过 `attachPortalSessionRoutes` 组合 Session 路由并注入 snapshot / proxy 依赖 | | `conversation-display.mjs` + `src/utils/message.ts` | 用户消息无 `displayText` 时,用 `deriveUserFacingText` 剥掉 routing / skill / 用户身份前缀 | | `chat-finish-sync.mjs` | 纯函数 merge 逻辑(被 TS 与单测共用) | diff --git a/mindspace-runtime-config.mjs b/mindspace-runtime-config.mjs index 13f5144..99f8fc0 100644 --- a/mindspace-runtime-config.mjs +++ b/mindspace-runtime-config.mjs @@ -11,6 +11,11 @@ import { resolvePublishDir, } from './user-publish.mjs'; +export function resolvePortalH5Root(codeRoot, env = process.env) { + const configuredRoot = String(env.MEMIND_PORTAL_H5_ROOT ?? '').trim(); + return path.resolve(configuredRoot || codeRoot); +} + export function resolvePageWorkspaceRelativePath(row) { const fromColumn = normalizeWorkspaceRelativePath(row?.workspace_relative_path); if (fromColumn) return fromColumn; diff --git a/mindspace-runtime-config.test.mjs b/mindspace-runtime-config.test.mjs index be79cac..f20d83b 100644 --- a/mindspace-runtime-config.test.mjs +++ b/mindspace-runtime-config.test.mjs @@ -12,10 +12,24 @@ import { resolveMindSpaceServerRuntimeOptions, resolveMindSpaceStorageRoot, resolveMindSpaceUserPublishDir, + resolvePortalH5Root, resolveWorkspaceMindSpacePublicUrl, isPublicWorkspaceHtmlRelativePath, } from './mindspace-runtime-config.mjs'; +test('resolvePortalH5Root decouples persistent workspace data from the code directory', () => { + assert.equal( + resolvePortalH5Root('/private/tmp/memind-worktree', { + MEMIND_PORTAL_H5_ROOT: '/Users/john/Project/Memind', + }), + path.resolve('/Users/john/Project/Memind'), + ); + assert.equal( + resolvePortalH5Root('/private/tmp/memind-worktree', {}), + path.resolve('/private/tmp/memind-worktree'), + ); +}); + test('resolveMindSpaceStorageRoot uses env override when present', () => { const root = resolveMindSpaceStorageRoot('/tmp/h5', { MINDSPACE_STORAGE_ROOT: '/srv/mindspace-data' }); assert.equal(root, path.resolve('/srv/mindspace-data')); diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index 3e93c0a..ccfd046 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -20,7 +20,11 @@ import { shouldAutoCreateReminderAtStart } from './schedule-service.mjs'; import { renderLongImage } from './mindspace-long-image.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; import { writePageAccessPolicy, readPageAccessPolicy } from './page-data-policy-store.mjs'; -import { closePolicyDataset, normalizePageAccessPolicy } from './page-access-policy.mjs'; +import { + assertPageAccessPolicyMatchesRegistry, + closePolicyDataset, + normalizePageAccessPolicy, +} from './page-access-policy.mjs'; import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs'; import { bindWorkspaceHtmlForPageData } from './page-data-workspace-bind.mjs'; import { resolveMindSpaceStorageRoot } from './mindspace-runtime-config.mjs'; @@ -692,8 +696,14 @@ async function callTool(name, args) { return [{ type: 'text', text: JSON.stringify(dataset, null, 2) }]; } case 'private_data_set_page_policy': { - const ownerUserId = String(args.ownerUserId ?? PRIVATE_DATA_USER_ID ?? '').trim(); + const ownerUserId = String(PRIVATE_DATA_USER_ID ?? '').trim(); if (!ownerUserId) throw new Error('缺少 ownerUserId,无法保存页面数据策略'); + const requestedOwnerUserId = String(args.ownerUserId ?? ownerUserId).trim(); + if (requestedOwnerUserId !== ownerUserId) { + throw Object.assign(new Error('ownerUserId 必须是当前会话用户'), { + code: 'forbidden', + }); + } const policy = normalizePageAccessPolicy( { pageId: args.pageId, @@ -706,6 +716,7 @@ async function callTool(name, args) { }, { fallbackPageId: args.pageId, fallbackOwnerUserId: ownerUserId }, ); + await assertPageAccessPolicyMatchesRegistry(policy, getUserDataSpaceService()); const saved = writePageAccessPolicy(SANDBOX, policy); if (isQuotaSyncConfigured()) { await upsertPageDataPolicyIndex(getQuotaPool(), saved).catch(() => null); diff --git a/mindspace-sandbox-mcp.test.mjs b/mindspace-sandbox-mcp.test.mjs index 8116318..ca32c02 100644 --- a/mindspace-sandbox-mcp.test.mjs +++ b/mindspace-sandbox-mcp.test.mjs @@ -279,6 +279,44 @@ test('sandbox MCP registers datasets and page policies for Page Data API', async assert.equal(saved.pageId, 'page-1'); assert.equal(saved.datasets.signups.insert, true); + const fictional = await server.request('tools/call', { + name: 'private_data_set_page_policy', + arguments: { + pageId: 'page-fictional', + accessMode: 'password', + datasets: { + split_crud_uat_20260724: { + read: true, + insert: true, + columns: { read: ['id'], insert: ['title'] }, + }, + }, + }, + }); + assert.equal(fictional.result.isError, true); + assert.match(fictional.result.content[0].text, /dataset 未注册/); + assert.equal( + fs.existsSync(path.join(root, '.mindspace', 'page-data-policies', 'page-fictional.json')), + false, + ); + + const wrongOwner = await server.request('tools/call', { + name: 'private_data_set_page_policy', + arguments: { + pageId: 'page-wrong-owner', + ownerUserId: 'user-2', + accessMode: 'public', + datasets: { + signups: { + insert: true, + columns: { insert: ['name'] }, + }, + }, + }, + }); + assert.equal(wrongOwner.result.isError, true); + assert.match(wrongOwner.result.content[0].text, /当前会话用户/); + const close = await server.request('tools/call', { name: 'private_data_close_page_dataset', arguments: { pageId: 'page-1', dataset: 'signups' }, diff --git a/mindspace-workspace-page-deliver.mjs b/mindspace-workspace-page-deliver.mjs index 36781bd..8e27377 100644 --- a/mindspace-workspace-page-deliver.mjs +++ b/mindspace-workspace-page-deliver.mjs @@ -36,6 +36,22 @@ function isPageDataWorkspaceHtml(publishDir, relativePath) { return htmlUsesPageDataApi(content); } +function filterWorkspacePagesByRelativePaths(rows, onlyRelativePaths) { + if (onlyRelativePaths == null) return rows; + const allowed = new Set( + [...onlyRelativePaths] + .map((relativePath) => normalizeWorkspaceRelativePath(relativePath)) + .filter(Boolean), + ); + return rows.filter((row) => { + const snapshot = parseJsonColumn(row.source_snapshot_json); + const relativePath = normalizeWorkspaceRelativePath( + row.workspace_relative_path ?? snapshot.relative_path ?? null, + ); + return relativePath ? allowed.has(relativePath) : false; + }); +} + export function createWorkspacePageDeliverService({ pool, pageService, @@ -47,7 +63,7 @@ export function createWorkspacePageDeliverService({ findPageByRelativePath = null, logger = console, } = {}) { - async function listUnpublishedAutoSyncedWorkspacePages(userId) { + async function listUnpublishedAutoSyncedWorkspacePages(userId, { onlyRelativePaths = null } = {}) { if (!pool || !userId) return []; const [rows] = await pool.query( `SELECT p.id AS page_id, p.title, p.current_version_id, p.workspace_relative_path, @@ -61,15 +77,16 @@ export function createWorkspacePageDeliverService({ AND pr.id IS NULL`, [userId], ); - return (rows ?? []).filter((row) => { + const candidates = (rows ?? []).filter((row) => { const snapshot = parseJsonColumn(row.source_snapshot_json); const relativePath = row.workspace_relative_path ?? snapshot.relative_path ?? null; if (!isPublicWorkspaceHtmlPath(relativePath)) return false; return snapshot.auto_synced === true || snapshot.content_mode === 'static_html'; }); + return filterWorkspacePagesByRelativePaths(candidates, onlyRelativePaths); } - async function listOnlineAutoSyncedWorkspacePages(userId) { + async function listOnlineAutoSyncedWorkspacePages(userId, { onlyRelativePaths = null } = {}) { if (!pool || !userId) return []; const [rows] = await pool.query( `SELECT p.id AS page_id, p.title, p.current_version_id, p.workspace_relative_path, @@ -82,12 +99,13 @@ export function createWorkspacePageDeliverService({ AND p.status <> 'deleted'`, [userId], ); - return (rows ?? []).filter((row) => { + const candidates = (rows ?? []).filter((row) => { const snapshot = parseJsonColumn(row.source_snapshot_json); const relativePath = row.workspace_relative_path ?? snapshot.relative_path ?? null; if (!isPublicWorkspaceHtmlPath(relativePath)) return false; return snapshot.auto_synced === true || snapshot.content_mode === 'static_html'; }); + return filterWorkspacePagesByRelativePaths(candidates, onlyRelativePaths); } function resolveWorkspaceRoot(userId) { @@ -115,11 +133,11 @@ export function createWorkspacePageDeliverService({ }); } - async function refreshOnlineWorkspacePublications(userId) { + async function refreshOnlineWorkspacePublications(userId, { onlyRelativePaths = null } = {}) { if (!publicationService?.refreshOnlinePublicationHtml || !userId) { return { refreshed: 0, skipped: 0, errors: [] }; } - const candidates = await listOnlineAutoSyncedWorkspacePages(userId); + const candidates = await listOnlineAutoSyncedWorkspacePages(userId, { onlyRelativePaths }); let refreshed = 0; let skipped = 0; const errors = []; @@ -145,11 +163,11 @@ export function createWorkspacePageDeliverService({ return { refreshed, skipped, errors }; } - async function ensureWorkspaceHtmlPublications(userId) { + async function ensureWorkspaceHtmlPublications(userId, { onlyRelativePaths = null } = {}) { if (!publicationService?.publish || !pageService?.getPage || !userId) { return { published: 0, skipped: 0, errors: [] }; } - const candidates = await listUnpublishedAutoSyncedWorkspacePages(userId); + const candidates = await listUnpublishedAutoSyncedWorkspacePages(userId, { onlyRelativePaths }); const publishDir = resolveWorkspaceRoot(userId); let published = 0; let skipped = 0; @@ -200,8 +218,12 @@ export function createWorkspacePageDeliverService({ const pageDataBindResult = await ensurePageDataBindings(userId, { onlyRelativePaths: pageDataRelativePaths, }); - const publishResult = await ensureWorkspaceHtmlPublications(userId); - const refreshResult = await refreshOnlineWorkspacePublications(userId); + const publishResult = await ensureWorkspaceHtmlPublications(userId, { + onlyRelativePaths: pageDataRelativePaths, + }); + const refreshResult = await refreshOnlineWorkspacePublications(userId, { + onlyRelativePaths: pageDataRelativePaths, + }); return { pageDataRelativePaths: pageDataRelativePaths == null ? null : [...pageDataRelativePaths], sync: syncResult, diff --git a/mindspace-workspace-page-deliver.test.mjs b/mindspace-workspace-page-deliver.test.mjs index f135694..eb75547 100644 --- a/mindspace-workspace-page-deliver.test.mjs +++ b/mindspace-workspace-page-deliver.test.mjs @@ -73,3 +73,89 @@ test('ensureWorkspaceHtmlPublications publishes auto-synced workspace html pages assert.equal(published[0].input.accessMode, 'public'); assert.equal(published[0].input.autoAcknowledgeFindings, true); }); + +test('syncAndDeliver scopes sync, bind, publish, and refresh to the current session paths', async () => { + const calls = { + sync: null, + bind: null, + published: [], + refreshed: [], + }; + const rows = [ + { + page_id: 'page-current', + title: '当前页', + current_version_id: 'ver-current', + workspace_relative_path: 'public/current.html', + source_snapshot_json: JSON.stringify({ + auto_synced: true, + relative_path: 'public/current.html', + content_mode: 'static_html', + }), + }, + { + page_id: 'page-stale', + title: '历史页', + current_version_id: 'ver-stale', + workspace_relative_path: 'public/stale.html', + source_snapshot_json: JSON.stringify({ + auto_synced: true, + relative_path: 'public/stale.html', + content_mode: 'static_html', + }), + }, + ]; + const service = createWorkspacePageDeliverService({ + h5Root: '/tmp', + pool: { + async query() { + return [rows]; + }, + }, + pageSyncService: { + async syncUserGeneratedPages(_userId, options) { + calls.sync = options.onlyRelativePaths; + return { created: 0, updated: 1, skipped: 0 }; + }, + }, + pageDataEnsure: { + async ensurePageDataHtmlPagesBound(options) { + calls.bind = options.onlyRelativePaths; + return { bound: [], skipped: [], errors: [] }; + }, + }, + pageService: { + async getPage(_userId, pageId) { + return { + id: pageId, + title: pageId, + currentVersionId: `ver-${pageId}`, + }; + }, + }, + publicationService: { + async getCurrent() { + return null; + }, + async publish(_userId, pageId) { + calls.published.push(pageId); + return { id: `pub-${pageId}` }; + }, + async refreshOnlinePublicationHtml(_userId, pageId) { + calls.refreshed.push(pageId); + return { id: `pub-${pageId}` }; + }, + }, + }); + + const currentPaths = ['public/current.html']; + const result = await service.syncAndDeliver('user-1', { + pageDataRelativePaths: currentPaths, + }); + + assert.deepEqual(calls.sync, currentPaths); + assert.deepEqual(calls.bind, currentPaths); + assert.deepEqual(calls.published, ['page-current']); + assert.deepEqual(calls.refreshed, ['page-current']); + assert.deepEqual(result.pageDataRelativePaths, currentPaths); +}); diff --git a/package.json b/package.json index 3c08e0f..adf2bd4 100644 --- a/package.json +++ b/package.json @@ -55,14 +55,14 @@ "smoke:memory-v2-external": "node scripts/smoke-memory-v2-external.mjs", "mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs", "test:memind": "node scripts/run-memind-tests.mjs", - "pretest": "node --test episodic-memory.test.mjs", + "pretest": "node scripts/verify-portal-access-policy.mjs && node --test episodic-memory.test.mjs tkmind-proxy.test.mjs server/portal-access-policy.test.mjs server/portal-account-feedback-routes.test.mjs server/portal-agent-job-routes.test.mjs server/portal-agent-runtime-routes.test.mjs server/portal-agent-services-bootstrap.test.mjs server/portal-auth-services-bootstrap.test.mjs server/portal-auth-session-helpers.test.mjs server/portal-billing-routes.test.mjs server/portal-core-auth-routes.test.mjs server/portal-domain-services-bootstrap.test.mjs server/portal-gateway-services-bootstrap.test.mjs server/portal-integration-services-bootstrap.test.mjs server/portal-memory-session-services-bootstrap.test.mjs server/portal-notification-routes.test.mjs server/portal-mindspace-agent-operation-routes.test.mjs server/portal-mindspace-asset-delivery-routes.test.mjs server/portal-mindspace-asset-routes.test.mjs server/portal-mindspace-chat-save-routes.test.mjs server/portal-mindspace-chat-share-routes.test.mjs server/portal-mindspace-page-core-routes.test.mjs server/portal-mindspace-page-publish-routes.test.mjs server/portal-mindspace-space-routes.test.mjs server/portal-publication-routes.test.mjs server/portal-publication-shell.test.mjs server/portal-published-page-delivery.test.mjs server/portal-session-coordinator.test.mjs server/portal-session-routes.test.mjs server/portal-plaza-routes.test.mjs server/portal-static-delivery-routes.test.mjs server/portal-user-memory-routes.test.mjs server/portal-wechat-auth-routes.test.mjs server/portal-webhook-routes.test.mjs server/portal-wiki-routes.test.mjs server/portal-workspace-publication-delivery.test.mjs", "test:scenario": "node scripts/run-scenario-test.mjs", "verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.mjs", "dev:page-data-aider-loop": "node scripts/page-data-aider-dev-loop.mjs", "ci:page-data-dev-loop-smoke": "node scripts/ci-page-data-dev-loop-smoke.mjs", "migrate:agent-code-run-config": "node scripts/migrate-agent-code-run-config-from-env.mjs", "test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs", "test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs", "test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs", @@ -71,6 +71,7 @@ "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", "verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime", "verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs", + "verify:portal-access-policy": "node scripts/verify-portal-access-policy.mjs", "verify:public-page-interaction": "node scripts/verify-public-page-interaction.mjs", "verify:page-data": "node --test mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs page-data-delivery-assess.test.mjs mindspace-page-data-finish-guard.test.mjs mindspace-page-data-finish-guard.integration.test.mjs", "verify:excel-analyst": "node --test excel-analyst.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs capabilities.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs session-reconcile.test.mjs tkmind-proxy-attachment.test.mjs", diff --git a/page-access-policy.mjs b/page-access-policy.mjs index 8c43860..f5be4f0 100644 --- a/page-access-policy.mjs +++ b/page-access-policy.mjs @@ -105,6 +105,102 @@ export function buildEffectiveDataset(registryDataset, policyDataset) { }); } +export async function assertPageAccessPolicyMatchesRegistry(policy, userDataSpace) { + if (!userDataSpace?.getDataset) { + throw Object.assign(new Error('缺少 Page Data 数据空间'), { + code: 'feature_disabled', + status: 503, + }); + } + + const actionFlags = [ + ['read', 'read'], + ['insert', 'insert'], + ['update', 'update'], + ['softDelete', 'soft_delete'], + ['hardDelete', 'hard_delete'], + ]; + const columnGroups = ['read', 'insert', 'update', 'soft_delete']; + + for (const [datasetName, policyDataset] of Object.entries(policy?.datasets ?? {})) { + const registryDataset = await userDataSpace.getDataset(datasetName); + if (!registryDataset) { + throw Object.assign(new Error(`dataset 未注册:${datasetName}`), { + code: 'dataset_not_found', + status: 404, + datasetName, + }); + } + + const tableColumns = await userDataSpace.listTableColumns?.(registryDataset.table); + if (!Array.isArray(tableColumns) || tableColumns.length === 0) { + throw Object.assign(new Error(`dataset 对应表不存在:${registryDataset.table}`), { + code: 'table_not_found', + status: 404, + datasetName, + table: registryDataset.table, + }); + } + const actualColumnNames = new Set(tableColumns.map((column) => column.name)); + const missingRegistryColumns = [ + ...new Set(Object.values(registryDataset.columns ?? {}).flat()), + ].filter((column) => !actualColumnNames.has(column)); + if (missingRegistryColumns.length) { + throw Object.assign( + new Error( + `dataset「${datasetName}」注册字段不存在:${missingRegistryColumns.join(', ')}`, + ), + { + code: 'dataset_schema_mismatch', + status: 409, + datasetName, + columns: missingRegistryColumns, + }, + ); + } + + const registeredActions = new Set(registryDataset.actions ?? []); + for (const [policyFlag, registryAction] of actionFlags) { + if (policyDataset?.[policyFlag] && !registeredActions.has(registryAction)) { + throw Object.assign( + new Error(`dataset「${datasetName}」未注册 ${registryAction} 权限`), + { + code: 'action_not_allowed', + status: 403, + datasetName, + action: registryAction, + }, + ); + } + } + + for (const action of columnGroups) { + const requestedColumns = policyDataset?.columns?.[action]; + if (!Array.isArray(requestedColumns)) continue; + const registeredColumns = new Set(registryDataset.columns?.[action] ?? []); + const unregisteredColumns = requestedColumns.filter( + (column) => !registeredColumns.has(column), + ); + if (unregisteredColumns.length) { + throw Object.assign( + new Error( + `dataset「${datasetName}」的 ${action} 字段未注册:${unregisteredColumns.join(', ')}`, + ), + { + code: 'columns_not_allowed', + status: 403, + datasetName, + action, + columns: unregisteredColumns, + }, + ); + } + } + } + + return policy; +} + export function defaultDatasetsForAccessMode(accessMode, datasets = {}) { const normalized = {}; for (const [name, config] of Object.entries(datasets)) { diff --git a/page-access-policy.test.mjs b/page-access-policy.test.mjs index e565fbb..89822e7 100644 --- a/page-access-policy.test.mjs +++ b/page-access-policy.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + assertPageAccessPolicyMatchesRegistry, buildEffectiveDataset, closePolicyDataset, normalizePageAccessPolicy, @@ -53,6 +54,52 @@ test('buildEffectiveDataset intersects registry actions with policy permissions' assert.deepEqual(effective.columns.insert, ['name', 'phone']); }); +test('policy registry validation rejects declared columns missing from the real table', async () => { + const policy = normalizePageAccessPolicy({ + pageId: 'page-1', + ownerUserId: 'user-1', + accessMode: 'password', + datasets: { + split_crud_uat_20260724: { + read: true, + update: true, + softDelete: true, + columns: { + read: ['id', 'title', 'updated_at', 'deleted_at', 'deleted_by'], + update: ['title', 'updated_at'], + soft_delete: ['deleted_at', 'deleted_by'], + }, + }, + }, + }); + const dataSpace = { + async getDataset() { + return { + name: 'split_crud_uat_20260724', + table: 'split_crud_uat_20260724', + actions: ['read', 'update', 'soft_delete'], + columns: { + read: ['id', 'title', 'updated_at', 'deleted_at', 'deleted_by'], + update: ['title', 'updated_at'], + soft_delete: ['deleted_at', 'deleted_by'], + }, + }; + }, + async listTableColumns() { + return [{ name: 'id' }, { name: 'title' }]; + }, + }; + + await assert.rejects( + () => assertPageAccessPolicyMatchesRegistry(policy, dataSpace), + (error) => + error.code === 'dataset_schema_mismatch' && + error.status === 409 && + error.columns.includes('updated_at') && + error.columns.includes('deleted_at'), + ); +}); + test('policyAllowsAction respects per-dataset flags', () => { const policy = normalizePageAccessPolicy({ pageId: 'page-1', diff --git a/page-data-integration.test.mjs b/page-data-integration.test.mjs index 4b113b0..be3de12 100644 --- a/page-data-integration.test.mjs +++ b/page-data-integration.test.mjs @@ -6,9 +6,15 @@ import test from 'node:test'; import express from 'express'; import { attachPageDataRoutes } from './page-data-routes.mjs'; import { createPageDataService } from './page-data-service.mjs'; -import { createPageDataPublicService } from './page-data-public-service.mjs'; +import { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; import { publicationInternals } from './mindspace-publications.mjs'; +import { writePageAccessPolicy } from './page-data-policy-store.mjs'; +import { + resolvePortalAccessEnforcementConfig, + resolvePortalAccessEnforcementDecision, + shouldOverridePortalLegacyGlobalAuth, +} from './server/portal-access-policy.mjs'; const PAGE_ID = 'page-integration-1'; const OWNER_ID = 'user-integration-1'; @@ -110,6 +116,42 @@ function buildApp(workspaceRoot) { return app; } +function buildEnforcedPageDataApp(workspaceRoot) { + const pageDataPublicService = createPageDataPublicService({ + getPool: () => createPool('public'), + resolveWorkspaceRootForOwner: () => workspaceRoot, + }); + const enforcementConfig = resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1', + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: 'page-data-public', + }); + const api = express.Router(); + api.use(express.json()); + api.use((req, res, next) => { + const decision = resolvePortalAccessEnforcementDecision( + { path: req.path, method: req.method }, + { isPageDataPublicPath }, + enforcementConfig, + ); + if (shouldOverridePortalLegacyGlobalAuth(decision, false)) return next(); + return res.status(401).json({ error: { code: 'unauthorized' } }); + }); + attachPageDataRoutes(api, { + sendData: (res, _req, data, status = 200) => res.status(status).json({ data }), + sendError: (res, _req, status, code, message) => + res.status(status).json({ error: { code, message } }), + getPageDataService: () => + createPageDataService({ + resolveWorkspaceRoot: async () => workspaceRoot, + }), + getPageDataPublicService: () => pageDataPublicService, + }); + const app = express(); + app.set('trust proxy', true); + app.use('/api', api); + return app; +} + async function request(app, method, url, { body, headers } = {}) { const server = app.listen(0); try { @@ -140,6 +182,21 @@ test('integration: owner private API and public insert coexist without breaking assert.equal(ownerInsert.status, 201); assert.equal(ownerInsert.body.data.row.title, 'owner 写入'); + const ownerInvalidInsert = await request( + app, + 'POST', + '/api/admin/page-data/entries/rows', + { + headers: { 'x-test-user': '1' }, + body: { forbidden_column: 'blocked' }, + }, + ); + assert.equal(ownerInvalidInsert.status, 403); + assert.equal( + ownerInvalidInsert.body.error.code, + 'columns_not_allowed', + ); + const ownerList = await request(app, 'GET', '/api/admin/page-data/entries?limit=10', { headers: { 'x-test-user': '1' }, }); @@ -184,6 +241,55 @@ test('integration: owner private API and public insert coexist without breaking assert.equal(stats.total, 2); }); +test('integration: page-data-public enforcement delegates to route policy without opening adjacent APIs', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-enforced-')); + await setupWorkspace(workspaceRoot); + writePageAccessPolicy(workspaceRoot, { + pageId: PAGE_ID, + ownerUserId: OWNER_ID, + accessMode: 'public', + datasets: { + entries: { + read: false, + insert: true, + columns: { + read: ['id', 'title', 'created_at'], + insert: ['title'], + }, + }, + }, + }); + const app = buildEnforcedPageDataApp(workspaceRoot); + + const inserted = await request( + app, + 'POST', + `/api/public/pages/${PAGE_ID}/data/entries/rows`, + { body: { title: '灰度公开写入' } }, + ); + assert.equal(inserted.status, 201); + assert.equal(inserted.body.data.row.title, '灰度公开写入'); + + const policyDenied = await request( + app, + 'GET', + `/api/public/pages/${PAGE_ID}/data/entries`, + ); + assert.equal(policyDenied.status, 403); + assert.equal(policyDenied.body.error.code, 'action_not_allowed'); + + for (const [method, url] of [ + ['GET', '/api/admin/page-data/entries'], + ['GET', '/api/plaza/v1/categories'], + ['GET', `/api/public/pages/${PAGE_ID}/data/entries/rows`], + ['POST', `/api/public/pages/${PAGE_ID}/data/entries`], + ]) { + const denied = await request(app, method, url); + assert.equal(denied.status, 401, `${method} ${url}`); + assert.equal(denied.body.error.code, 'unauthorized'); + } +}); + test('integration: public insert rejects SQL injection style payload keys', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-integration-sql-')); await setupWorkspace(workspaceRoot); diff --git a/page-data-public-service.mjs b/page-data-public-service.mjs index bb76625..dae1c68 100644 --- a/page-data-public-service.mjs +++ b/page-data-public-service.mjs @@ -3,6 +3,7 @@ import { publicationInternals } from './mindspace-publications.mjs'; import { buildEffectiveDataset, buildPageDataPolicyFromPublishInput, + assertPageAccessPolicyMatchesRegistry, closePolicyDataset, normalizePageAccessPolicy, policyAllowsAction, @@ -587,6 +588,7 @@ export function createPageDataPublicService(deps = {}) { }, { fallbackPageId: pageId, fallbackOwnerUserId: user.id }, ); + await assertPageAccessPolicyMatchesRegistry(policy, createOwnerService(user.id)); const saved = writePageAccessPolicy(workspaceRoot, policy); await syncPolicyIndex(saved); return saved; diff --git a/page-data-public-service.test.mjs b/page-data-public-service.test.mjs index 91b2036..f658aa7 100644 --- a/page-data-public-service.test.mjs +++ b/page-data-public-service.test.mjs @@ -7,25 +7,32 @@ import { createPageDataPublicService, isPageDataPublicPath } from './page-data-p import { createUserDataSpaceService } from './user-data-space-service.mjs'; import { writePageAccessPolicy, readPageAccessPolicy } from './page-data-policy-store.mjs'; import { publicationInternals } from './mindspace-publications.mjs'; +import { createPageDataSessionStore } from './page-data-session-store.mjs'; const PAGE_ID = 'page-public-1'; const OWNER_ID = 'user-owner-1'; -function createPublicationPool({ accessMode = 'public', password = null } = {}) { +function createPublicationPool({ + accessMode = 'public', + password = null, + pageId = PAGE_ID, + expiresAt = null, +} = {}) { const passwordHash = password ? publicationInternals.hashPassword(password) : null; return { - async query(sql) { + async query(sql, params = []) { if (sql.includes('FROM h5_publish_records')) { + if (params[0] !== pageId) return [[]]; return [ [ { id: 'pub-record-1', user_id: OWNER_ID, - page_id: PAGE_ID, + page_id: pageId, access_mode: accessMode, password_hash: passwordHash, status: 'online', - expires_at: null, + expires_at: expiresAt, }, ], ]; @@ -80,10 +87,11 @@ async function setupPublicWorkspace(workspaceRoot, { accessMode = 'public', with return service; } -function createPublicService(workspaceRoot, poolOptions = {}) { +function createPublicService(workspaceRoot, poolOptions = {}, serviceOptions = {}) { return createPageDataPublicService({ getPool: () => createPublicationPool(poolOptions), resolveWorkspaceRootForOwner: () => workspaceRoot, + ...serviceOptions, }); } @@ -206,11 +214,133 @@ test('password page update and soft delete require token', async () => { assert.equal(deleted.deleted, true); }); +test('password page rejects missing, invalid, cross-page, and expired tokens', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-password-token-')); + await setupPublicWorkspace(workspaceRoot, { + accessMode: 'password', + withRead: true, + }); + const sessionStore = createPageDataSessionStore({ ttlMs: 60_000 }); + const service = createPublicService( + workspaceRoot, + { accessMode: 'password', password: 'team-2026' }, + { sessionStore }, + ); + + for (const headers of [{}, { 'x-page-data-token': 'invalid-token' }]) { + await assert.rejects( + () => service.listRows(PAGE_ID, 'signups', { headers }), + (error) => error.code === 'token_invalid' && error.status === 401, + ); + } + + const crossPage = sessionStore.issue({ + pageId: 'page-other', + ownerUserId: OWNER_ID, + publicationId: 'pub-other', + accessMode: 'password', + }); + await assert.rejects( + () => + service.listRows(PAGE_ID, 'signups', { + headers: { 'x-page-data-token': crossPage.token }, + }), + (error) => error.code === 'token_invalid' && error.status === 401, + ); + + const expiredStore = createPageDataSessionStore({ ttlMs: -1 }); + const expired = expiredStore.issue({ + pageId: PAGE_ID, + ownerUserId: OWNER_ID, + publicationId: 'pub-expired', + accessMode: 'password', + }); + const expiredService = createPublicService( + workspaceRoot, + { accessMode: 'password', password: 'team-2026' }, + { sessionStore: expiredStore }, + ); + await assert.rejects( + () => + expiredService.listRows(PAGE_ID, 'signups', { + headers: { 'x-page-data-token': expired.token }, + }), + (error) => error.code === 'token_invalid' && error.status === 401, + ); +}); + +test('public access never allows update or delete even when policy contains those actions', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-mutation-')); + const ownerService = await setupPublicWorkspace(workspaceRoot, { + accessMode: 'public', + withRead: true, + }); + const inserted = await ownerService.insertRowForDataset(ownerService.getDataset('signups'), { + name: '不可匿名修改', + status: 'pending', + }); + const service = createPublicService(workspaceRoot, { accessMode: 'public' }); + const req = { ip: '127.0.0.1', headers: {}, body: {} }; + + await assert.rejects( + () => + service.updateRow(PAGE_ID, 'signups', inserted.row.id, req, { + status: 'done', + }), + (error) => error.code === 'action_not_allowed' && error.status === 403, + ); + await assert.rejects( + () => service.deleteRow(PAGE_ID, 'signups', inserted.row.id, req), + (error) => error.code === 'action_not_allowed' && error.status === 403, + ); +}); + +test('expired, unknown, and invalid-policy publications fail closed', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-fail-closed-')); + await setupPublicWorkspace(workspaceRoot, { accessMode: 'public', withRead: true }); + + const expiredService = createPublicService(workspaceRoot, { + accessMode: 'public', + expiresAt: Date.now() - 1, + }); + await assert.rejects( + () => expiredService.listRows(PAGE_ID, 'signups', { headers: {} }), + (error) => error.code === 'publication_not_found' && error.status === 404, + ); + + const service = createPublicService(workspaceRoot, { accessMode: 'public' }); + await assert.rejects( + () => service.listRows('page-missing', 'signups', { headers: {} }), + (error) => error.code === 'publication_not_found' && error.status === 404, + ); + + writePageAccessPolicy(workspaceRoot, { + pageId: PAGE_ID, + ownerUserId: 'different-owner', + accessMode: 'public', + datasets: { + signups: { + read: true, + columns: { read: ['id', 'name'] }, + }, + }, + }); + await assert.rejects( + () => service.listRows(PAGE_ID, 'signups', { headers: {} }), + (error) => error.code === 'invalid_policy' && error.status === 400, + ); +}); + test('isPageDataPublicPath allows public page data routes without auth', () => { assert.equal(isPageDataPublicPath('/public/pages/page-1/data/signups/rows', 'POST'), true); assert.equal(isPageDataPublicPath('/public/pages/page-1/data-auth', 'POST'), true); assert.equal(isPageDataPublicPath('/public/pages/page-1/data/signups', 'GET'), true); assert.equal(isPageDataPublicPath('/page-data/signups', 'GET'), false); + assert.equal(isPageDataPublicPath('/public/pages/page-1/data/signups', 'POST'), false); + assert.equal(isPageDataPublicPath('/public/pages/page-1/data/signups/rows', 'GET'), false); + assert.equal(isPageDataPublicPath('/public/pages/page-1/dataevil/signups', 'GET'), false); + assert.equal(isPageDataPublicPath('/public/pages/page-1/data-auth', 'GET'), false); + assert.equal(isPageDataPublicPath('/admin/page-data/signups', 'GET'), false); }); async function setupCollaborationWorkspace(workspaceRoot) { @@ -423,3 +553,28 @@ test('saveOwnerPolicyFromPublish writes policy for published page', async () => const saved = readPageAccessPolicy(workspaceRoot, PAGE_ID); assert.equal(saved.datasets.signups.read, true); }); + +test('saveOwnerPolicy rejects unregistered datasets without overwriting the current policy', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-owner-policy-guard-')); + await setupPublicWorkspace(workspaceRoot, { accessMode: 'password', withRead: true }); + const service = createPublicService(workspaceRoot, { accessMode: 'password' }); + const user = { id: OWNER_ID, workspaceRoot }; + + await assert.rejects( + () => + service.saveOwnerPolicy(user, PAGE_ID, { + accessMode: 'password', + datasets: { + split_crud_uat_20260724: { + read: true, + insert: true, + columns: { read: ['id'], insert: ['title'] }, + }, + }, + }), + (error) => error.code === 'dataset_not_found' && error.status === 404, + ); + + const saved = readPageAccessPolicy(workspaceRoot, PAGE_ID); + assert.deepEqual(Object.keys(saved.datasets), ['signups']); +}); diff --git a/page-data-service.mjs b/page-data-service.mjs index 9124624..2884f76 100644 --- a/page-data-service.mjs +++ b/page-data-service.mjs @@ -91,7 +91,7 @@ export function createPageDataService(deps = {}) { async function insertRow(user, datasetName, payload) { const { service } = await createServiceForUser(user); try { - return service.insertDatasetRow(datasetName, payload); + return await service.insertDatasetRow(datasetName, payload); } catch (error) { throw mapServiceError(error); } @@ -100,7 +100,7 @@ export function createPageDataService(deps = {}) { async function updateRow(user, datasetName, rowId, payload) { const { service } = await createServiceForUser(user); try { - return service.updateDatasetRow(datasetName, rowId, payload); + return await service.updateDatasetRow(datasetName, rowId, payload); } catch (error) { throw mapServiceError(error); } @@ -109,7 +109,9 @@ export function createPageDataService(deps = {}) { async function softDeleteRow(user, datasetName, rowId) { const { service } = await createServiceForUser(user); try { - return service.softDeleteDatasetRow(datasetName, rowId, { deletedBy: user.id }); + return await service.softDeleteDatasetRow(datasetName, rowId, { + deletedBy: user.id, + }); } catch (error) { throw mapServiceError(error); } @@ -118,7 +120,7 @@ export function createPageDataService(deps = {}) { async function restoreRow(user, datasetName, rowId) { const { service } = await createServiceForUser(user); try { - return service.restoreSoftDeletedRow(datasetName, rowId); + return await service.restoreSoftDeletedRow(datasetName, rowId); } catch (error) { throw mapServiceError(error); } @@ -127,7 +129,7 @@ export function createPageDataService(deps = {}) { async function exportDataset(user, datasetName, query = {}) { const { service } = await createServiceForUser(user); try { - return service.exportDatasetRows(datasetName, { + return await service.exportDatasetRows(datasetName, { format: query.format, includeDeleted: ['1', 'true', 'yes'].includes(String(query.include_deleted ?? '').toLowerCase()), limit: query.limit, diff --git a/scripts/verify-chat-finish-sync.mjs b/scripts/verify-chat-finish-sync.mjs index a876513..4963302 100644 --- a/scripts/verify-chat-finish-sync.mjs +++ b/scripts/verify-chat-finish-sync.mjs @@ -63,8 +63,22 @@ assertIncludes(voiceInputButton, 'useVoiceSession({', 'VoiceInputButton.tsx'); assertIncludes(voiceInputButton, 'onTranscript?.(transcript)', 'VoiceInputButton.tsx'); const server = read('server.mjs'); -assertIncludes(server, 'canUseSnapshotCache', 'server.mjs'); -assertIncludes(server, 'hintMc != null && hintUa != null', 'server.mjs'); +assertIncludes( + server, + 'attachPortalSessionRoutes(api, {', + 'server.mjs session route composition', +); +const portalSessionRoutes = read('server/portal-session-routes.mjs'); +assertIncludes( + portalSessionRoutes, + 'canUseSnapshotCache', + 'portal-session-routes.mjs', +); +assertIncludes( + portalSessionRoutes, + 'hintMc != null && hintUa != null', + 'portal-session-routes.mjs', +); const publicFinishSync = read('mindspace-public-finish-sync.mjs'); assertIncludes(publicFinishSync, 'applyPublicHtmlEdit', 'mindspace-public-finish-sync.mjs'); diff --git a/scripts/verify-portal-access-policy.mjs b/scripts/verify-portal-access-policy.mjs new file mode 100644 index 0000000..d917d8d --- /dev/null +++ b/scripts/verify-portal-access-policy.mjs @@ -0,0 +1,93 @@ +import { isLegacyPageDataApiPath } from '../page-data-routes.mjs'; +import { isPageDataPublicPath } from '../page-data-public-service.mjs'; +import { + assessPortalAccessMigrationReadiness, + PORTAL_STATIC_ACCESS_RULES, + resolvePortalAccessEnforcementConfig, +} from '../server/portal-access-policy.mjs'; + +const routeSamples = [ + { path: '/status', method: 'GET' }, + { path: '/runtime/status', method: 'GET' }, + { path: '/config/blocked-words', method: 'GET' }, + { path: '/internal/agent/jobs/job-1/claim', method: 'POST' }, + { path: '/agent/mindspace_page_patch', method: 'POST' }, + { path: '/internal/image-make/runtime-config', method: 'GET' }, + { path: '/mindspace/v1/assets/asset-1/download', method: 'GET' }, + { path: '/plaza/v1/feed', method: 'GET' }, + { path: '/plaza/v1/categories', method: 'GET' }, + { path: '/plaza/v1/seo/sitemap', method: 'GET' }, + { path: '/plaza/v1/posts/post-1', method: 'GET' }, + { path: '/plaza/v1/posts/post-1/comments', method: 'GET' }, + { path: '/plaza/v1/users/alice', method: 'GET' }, + { path: '/plaza/v1/users/alice/posts', method: 'GET' }, + { path: '/plaza/v1/events', method: 'POST' }, + { path: '/plaza/v1/posts/post-1/reports', method: 'POST' }, + { path: '/public/pages/page-1/data/signups', method: 'GET' }, + { path: '/public/pages/page-1/data/signups/rows', method: 'POST' }, + { path: '/page-data/signups', method: 'GET' }, + { path: '/mindspace/v1/pages', method: 'GET' }, +]; + +const predicates = { + isPageDataPublicPath, + isLegacyPageDataApiPath, +}; +const knownMismatchPolicyGroups = [ + 'plaza-optional-user', + 'page-data-public', + 'legacy-page-data', +]; + +const staticRuleIds = PORTAL_STATIC_ACCESS_RULES.map((rule) => rule.id); +if (new Set(staticRuleIds).size !== staticRuleIds.length) { + throw new Error('Portal access policy contains duplicate static rule ids'); +} +if (resolvePortalAccessEnforcementConfig({}).enabled) { + throw new Error('Portal access enforcement must default to disabled'); +} +try { + resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1', + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: 'all', + }); + throw new Error('Portal access enforcement unexpectedly accepted all'); +} catch (error) { + if (!String(error?.message ?? error).includes('Unknown Portal access enforcement group')) { + throw error; + } +} + +const noMultiUserReport = assessPortalAccessMigrationReadiness(routeSamples, { + ...predicates, + multiUserEnabled: false, + knownMismatchPolicyGroups, +}); +const multiUserReport = assessPortalAccessMigrationReadiness(routeSamples, { + ...predicates, + multiUserEnabled: true, +}); + +if (!noMultiUserReport.contractReady) { + throw new Error( + `Portal access policy has ${noMultiUserReport.unexpectedMismatches.length} unexpected no-auth/legacy mismatch(es)`, + ); +} +if (!multiUserReport.safeForBehaviorSwitch) { + throw new Error( + `Portal access policy has ${multiUserReport.knownMismatches.length + multiUserReport.unexpectedMismatches.length} multi-user mismatch(es)`, + ); +} + +console.log( + [ + 'portal access policy compatibility ok', + `samples=${routeSamples.length}`, + `static_rules=${staticRuleIds.length}`, + `known_no_auth_mismatches=${noMultiUserReport.knownMismatches.length}`, + `unexpected_no_auth_mismatches=${noMultiUserReport.unexpectedMismatches.length}`, + `multi_user_mismatches=${multiUserReport.knownMismatches.length + multiUserReport.unexpectedMismatches.length}`, + `contract_ready=${noMultiUserReport.contractReady && multiUserReport.contractReady}`, + `behavior_switch_ready=${noMultiUserReport.safeForBehaviorSwitch && multiUserReport.safeForBehaviorSwitch}`, + ].join(' '), +); diff --git a/server.mjs b/server.mjs index 7a7bf42..477e360 100644 --- a/server.mjs +++ b/server.mjs @@ -5,224 +5,106 @@ import fsPromises from 'node:fs/promises'; import { createProxyMiddleware } from 'http-proxy-middleware'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - AUTH_COOKIE, - clearSessionCookie, - createAuthManager, - parseCookies, - sessionCookie, -} from './auth.mjs'; -import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs'; -import { createWorkspacePageDeliverService } from './mindspace-workspace-page-deliver.mjs'; -import { createUserDataSpaceService } from './user-data-space-service.mjs'; -import { createToolGateway } from './tool-gateway.mjs'; -import { createAssetGatewayConfigService } from './asset-gateway.mjs'; -import { createImageMakeAdminConfigService } from './image-make-admin-config.mjs'; -import { createImageMakeClientFromEnv } from './image-make-client.mjs'; -import { createMindSpaceImageGenerationService } from './mindspace-image-generation.mjs'; -import { createMindSpaceImageReviewService } from './mindspace-image-review.mjs'; +import { createAuthManager } from './auth.mjs'; +import { createDbPool, isDatabaseConfigured } from './db.mjs'; import { attachMindSpaceImageGenerationRoutes } from './mindspace-image-generation-routes.mjs'; -import { createAgentRunGateway } from './agent-run-gateway.mjs'; -import { - createAgentRunEventsHandler, - createGetAgentRunHandler, - createPostAgentRunsHandler, -} from './agent-run-routes.mjs'; -import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs'; -import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs'; -import { isSessionBrokerMetricsEnabled } from './session-broker-metrics.mjs'; -import { - clearUserLogoutCookies, - clearUserSessionCookie, - createUserAuth, - resolveCookieDomainForRequest, - USER_COOKIE, - userLoginCookies, - userSessionCookie, -} from './user-auth.mjs'; +import { sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs'; import { createWikiAuth } from './wiki-auth.mjs'; import { isLocalDevHostname } from './scripts/local-test-config.mjs'; -import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR } from './user-publish.mjs'; -import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; -import { injectMindSpaceAnalytics, resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, resolveMindSpaceAnalyticsConfig, sendMindSpaceAnalyticsEvent } from './mindspace-analytics.mjs'; +import { PUBLISH_ROOT_DIR } from './user-publish.mjs'; +import { startWorkspaceThumbnailWatcher } from './mindspace-workspace-thumbnails.mjs'; +import { resolveMindSpaceAnalyticsConfig, sendMindSpaceAnalyticsEvent } from './mindspace-analytics.mjs'; import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs'; import { attachRequestId, sendData, sendError } from './api-response.mjs'; -import { createNotificationDispatcher } from './notification-dispatcher.mjs'; -import { createMindSpaceAuditWriter } from './mindspace-audit.mjs'; -import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs'; -import { createMindSpaceService } from './mindspace.mjs'; -import { ensureMindSpaceConfig, loadMindSpaceConfig } from './mindspace-config.mjs'; -import { createMindSearchConfigService } from './mindsearch-config.mjs'; -import { pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; +import { attachPortalAgentJobRoutes } from './server/portal-agent-job-routes.mjs'; +import { attachPortalAgentRuntimeRoutes } from './server/portal-agent-runtime-routes.mjs'; import { - createDownloadConversationPackageArtifactHandler, - createDownloadConversationPackageManifestHandler, - createGetConversationPackageHandler, -} from './mindspace-conversation-package-routes.mjs'; + attachPortalBlockedWordsRoute, + attachPortalImageMakeRuntimeConfigRoute, +} from './server/portal-config-routes.mjs'; +import { + createPortalAccessShadowReporter, + resolvePortalAccessEnforcementConfig, + resolvePortalAccessPolicyMode, + resolvePortalAccessShadowLogIntervalMs, +} from './server/portal-access-policy.mjs'; +import { createPortalApiAuthMiddleware } from './server/portal-api-auth-middleware.mjs'; +import { attachPortalAccountFeedbackRoutes } from './server/portal-account-feedback-routes.mjs'; +import { attachPortalBillingRoutes } from './server/portal-billing-routes.mjs'; +import { attachPortalCoreAuthRoutes } from './server/portal-core-auth-routes.mjs'; +import { attachPortalWechatAuthRoutes } from './server/portal-wechat-auth-routes.mjs'; +import { attachPortalWebhookRoutes } from './server/portal-webhook-routes.mjs'; +import { attachPortalWikiRoutes } from './server/portal-wiki-routes.mjs'; +import { bootstrapPortalAgentServices } from './server/portal-agent-services-bootstrap.mjs'; +import { bootstrapPortalAuthServices } from './server/portal-auth-services-bootstrap.mjs'; +import { bootstrapPortalDomainServices } from './server/portal-domain-services-bootstrap.mjs'; +import { bootstrapPortalGatewayServices } from './server/portal-gateway-services-bootstrap.mjs'; +import { bootstrapPortalIntegrationServices } from './server/portal-integration-services-bootstrap.mjs'; +import { bootstrapPortalMemorySessionServices } from './server/portal-memory-session-services-bootstrap.mjs'; +import { attachPortalNotificationRoutes } from './server/portal-notification-routes.mjs'; +import { attachPortalMindSpaceAssetDeliveryRoutes } from './server/portal-mindspace-asset-delivery-routes.mjs'; +import { attachPortalMindSpaceAgentOperationRoutes } from './server/portal-mindspace-agent-operation-routes.mjs'; +import { attachPortalMindSpaceAssetRoutes } from './server/portal-mindspace-asset-routes.mjs'; +import { attachPortalMindSpaceChatSaveRoutes } from './server/portal-mindspace-chat-save-routes.mjs'; +import { attachPortalMindSpaceChatShareRoutes } from './server/portal-mindspace-chat-share-routes.mjs'; +import { attachPortalMindSpacePageCoreRoutes } from './server/portal-mindspace-page-core-routes.mjs'; +import { attachPortalMindSpacePagePublishRoutes } from './server/portal-mindspace-page-publish-routes.mjs'; +import { attachPortalMindSpaceSpaceRoutes } from './server/portal-mindspace-space-routes.mjs'; +import { attachPortalPlazaDiscoveryRoutes } from './server/portal-plaza-discovery-routes.mjs'; +import { attachPortalPlazaRoutes } from './server/portal-plaza-routes.mjs'; +import { attachPortalPublicationRoutes } from './server/portal-publication-routes.mjs'; +import { createPortalPublishedPageDelivery } from './server/portal-published-page-delivery.mjs'; +import { + removeQueryParam, + resolveRequestOrigin, +} from './server/portal-publication-shell.mjs'; +import { attachPortalRuntimeRoutes } from './server/portal-runtime-routes.mjs'; +import { attachPortalStaticDeliveryRoutes } from './server/portal-static-delivery-routes.mjs'; +import { createPortalWorkspacePublicationDelivery } from './server/portal-workspace-publication-delivery.mjs'; +import { createPortalAuthSessionHelpers } from './server/portal-auth-session-helpers.mjs'; +import { createPortalSessionCoordinator } from './server/portal-session-coordinator.mjs'; +import { attachPortalSessionRoutes } from './server/portal-session-routes.mjs'; +import { attachPortalUserMemoryRoutes } from './server/portal-user-memory-routes.mjs'; +import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs'; +import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; import { registerPublicHtmlArtifactsForConversationPackage } from './mindspace-conversation-package-public-html.mjs'; import { - assertMindSpaceServerAdapterContract, - createMindSpaceServerAdapter, -} from './mindspace-server-adapter.mjs'; -import { - resolveMindSpacePublicRequest, -} from './mindspace-public-route.mjs'; -import { - buildPublishedHtmlViewContext, - injectPublishedPageDataContext, - parseMindSpacePublishFilePath, - resolvePublicRequestOrigin, -} from './mindspace-public-page-context.mjs'; -import { - preparePublicHtmlAssetDelivery, - verifyPublicAssetToken, -} from './mindspace-public-asset-token.mjs'; -import { - collectInlineScriptHashes, - decorateMindSpacePublishedHtml, - handleMindSpaceLongImageDownload, -} from './mindspace-public-delivery.mjs'; -import { - buildMindSpacePublicRoutePath, buildMindSpacePublicUrlForUser, - isPublicWorkspaceHtmlRelativePath, resolveMindSpaceRuntimeConfig, resolveMindSpacePublishRoot, resolveMindSpaceServerRuntimeOptions, resolveMindSpaceUserPublishDir, + resolvePortalH5Root, } from './mindspace-runtime-config.mjs'; -import { createPageEditSessionService } from './mindspace-page-edit-session.mjs'; -import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs'; import { publicationInternals, - rewriteBrokenMindSpacePublicImageUrls, - rewritePublicationCanonicalAssetUrls, - rewriteWorkspacePublicAssetReferences, } from './mindspace-publications.mjs'; -import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs'; -import { createPlazaEventService } from './plaza-events.mjs'; -import { createPlazaRecommendService } from './plaza-recommend.mjs'; -import { createPlazaInteractionService } from './plaza-interactions.mjs'; +import { mapPlazaError } from './plaza-posts.mjs'; +import { createNoopPlazaRedis } from './plaza-redis.mjs'; import { - ensureAlgorithmConfig, - loadAlgorithmConfig, - recalculateHotScores, -} from './plaza-algorithm.mjs'; -import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs'; -import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs'; -import { createPlazaSeoService } from './plaza-seo.mjs'; -import { createPlazaOpsService } from './plaza-ops.mjs'; -import { createWordFilterService } from './word-filter.mjs'; -import { - allowPlazaEmbedFrame, - preparePublicationHtmlForEmbed, - isPlazaEmbedRequest, - stripPublicationHtmlCspMeta, -} from './plaza-embed.mjs'; -import { publishedPageCsp } from './mindspace-published-page-csp.mjs'; -import { rewriteKnownCdnScriptSources } from './mindspace-published-script-localize.mjs'; -import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs'; -import { - analyzeChatMessageForSave, buildChatSavePreviewFrameUrl, buildChatSaveThumbnailUrl, buildWorkspaceAssetUrl, - buildWorkspaceBaseHref, buildWorkspaceThumbnailUrl, - injectHtmlBaseHref, - resolveClosestHtmlRelativePath, - createAssetDataUriResolver, - htmlReferencesPrivateAssets, materializePrivateAssetsInWorkspaceHtml, repairMissingHtmlAssetReferences, resolveChatSaveAnalysis, - resolveStaticHtmlContent, } from './mindspace-chat-save.mjs'; import { - collectPublicHtmlWritePathsFromSessionEvent, - materializePublicHtmlWritesFromSessionEvent, normalizePublicHtmlRelativePath, - syncPublicHtmlAfterFinish, } from './mindspace-public-finish-sync.mjs'; -import { getPageDeliveryContract, markPageDeliveryContractReady, preparePageDeliveryContract } from './mindspace-delivery-contract.mjs'; -import { maybeRepairH5HtmlAfterFinish } from './mindspace-h5-html-finish-guard.mjs'; -import { evaluatePageDataHtmlContent, maybeRepairPageDataAfterFinish } from './mindspace-page-data-finish-guard.mjs'; -import { ensurePageDataHtmlPagesBound } from './page-data-workspace-ensure.mjs'; -import { detectPageDataDatasetUsageFromHtml } from './page-data-html-detect.mjs'; -import { readPageAccessPolicy } from './page-data-policy-store.mjs'; -import { policyAllowsAction } from './page-access-policy.mjs'; -import { quickPlazaFromChat, quickPlazaFromPublicHtml, getQuickPlazaFromPublicHtmlStatus } from './mindspace-chat-plaza.mjs'; -import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs'; import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.mjs'; -import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; -import { - extractSharePreviewMeta, - injectOgTags, - injectWechatShareBridge, - renderWechatSharePreviewHtml, -} from './mindspace-og-tags.mjs'; -import { - ensureThumbnailPng, - rasterizeThumbnailSvgToPng, - thumbnailPngPathForSvg, -} from './mindspace-thumbnail-png.mjs'; -import { - isLongImageDownloadRequest, - longImagePathForHtml, - renderLongImage, - renderLongImageBuffer, -} from './mindspace-long-image.mjs'; -import { generateDocxBuffer } from './mindspace-docx-export.mjs'; -import { - DOCX_MIME_TYPE, - registerChatDocxArtifactForConversation, -} from './mindspace-chat-docx-package.mjs'; -import { scanContent } from './mindspace-content-scan.mjs'; -import { scanWorkspaceFilesForProhibitedBrowserStorage } from './mindspace-browser-storage-policy.mjs'; import { listRecentlyModifiedPublicHtmlRelativePaths } from './mindspace-run-public-html-scope.mjs'; -import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs'; import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES } from './user-image-normalize.mjs'; -import { createRechargeService } from './billing-recharge.mjs'; -import { createSubscriptionService, createPlanCatalogService, ensurePlanCatalogSchema, PLAN_CATALOG } from './billing-subscription.mjs'; -import { - createWechatPayClient, - loadWechatPayConfig, - WECHAT_NOTIFY_SUCCESS_V2, -} from './wechat-pay.mjs'; -import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs'; -import { exchangeMiniProgramCode, loadWechatMiniappConfig } from './wechat-miniapp.mjs'; import { loadWechatMpConfig } from './wechat-mp-config.mjs'; -import { loadWechatMpModule } from './wechat-mp-loader.mjs'; -import { validateWechatShareSignatureUrl } from './wechat-share.mjs'; -import { createScheduleService } from './schedule-service.mjs'; -import { createFeedbackService } from './user-feedback.mjs'; -import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs'; -import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs'; -import { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshot } from './direct-chat-service.mjs'; import { filterUserVisibleConversation, repairSessionConversationFromDb, restoreConversationUserMessagesFromAgentRunsFailOpen, } from './conversation-repair.mjs'; -import { filterNonemptyUserVisibleMessages } from './conversation-transcript-persist.mjs'; -import { createSessionStreamStore } from './session-stream-store.mjs'; -import { isSessionStreamReplayEnabled } from './session-stream.mjs'; -import { createManagedChatIntentRouter, resolveNormalizedRouterDecisionMode } from './chat-intent-router.mjs'; -import { createSessionSnapshotService } from './session-snapshot.mjs'; -import { createConversationMemoryService } from './conversation-memory.mjs'; -import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs'; -import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; -import { createEpisodicMemoryService } from './episodic-memory.mjs'; -import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs'; -import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-config.mjs'; -import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs'; -import { createExperienceService } from './experience-service.mjs'; import { attachAsrRoutes } from './asr-proxy.mjs'; import { attachPageDataRoutes, isLegacyPageDataApiPath } from './page-data-routes.mjs'; -import { createPageDataService } from './page-data-service.mjs'; -import { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs'; -import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs'; -import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs'; +import { isPageDataPublicPath } from './page-data-public-service.mjs'; import { attachShenmeiOpinionFormRoutes } from './shenmei-opinion-form-routes.mjs'; import { isNativeH5ApiPath } from './policies.mjs'; import { @@ -232,9 +114,20 @@ import { } from './scripts/memind-runtime-profile.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); - loadMemindEnvFiles(__dirname); -applyMemindRuntimeProfile({ rootDir: __dirname }); +const H5_ROOT = resolvePortalH5Root(__dirname, process.env); +if (!fs.existsSync(H5_ROOT) || !fs.statSync(H5_ROOT).isDirectory()) { + throw new Error( + `MEMIND_PORTAL_H5_ROOT must reference an existing directory: ${H5_ROOT}`, + ); +} +if (H5_ROOT !== __dirname) { + console.log(`[Portal] Code root: ${__dirname}`); + console.log(`[Portal] Persistent H5 root: ${H5_ROOT}`); + loadMemindEnvFiles(H5_ROOT); +} + +applyMemindRuntimeProfile({ rootDir: H5_ROOT }); function parseApiTargets() { const csvTargets = (process.env.TKMIND_API_TARGETS ?? '') @@ -254,13 +147,28 @@ const API_TARGETS = parseApiTargets(); const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006'; const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET; -const mindSpaceServerRuntime = resolveMindSpaceServerRuntimeOptions(__dirname, process.env); +const portalAccessPolicyMode = resolvePortalAccessPolicyMode(process.env); +const portalAccessEnforcementConfig = resolvePortalAccessEnforcementConfig(process.env); +const portalAccessShadowReporter = createPortalAccessShadowReporter({ + intervalMs: resolvePortalAccessShadowLogIntervalMs(process.env), + emit: (payload) => { + console.warn('[Auth][PortalAccessShadow]', JSON.stringify(payload)); + }, +}); +if (portalAccessEnforcementConfig.killSwitch) { + console.warn('[Auth][PortalAccessEnforce] kill switch active; all policy groups use legacy auth'); +} else if (portalAccessEnforcementConfig.enabled) { + console.log( + `[Auth][PortalAccessEnforce] groups enabled: ${portalAccessEnforcementConfig.activeGroups.join(', ')}`, + ); +} +const mindSpaceServerRuntime = resolveMindSpaceServerRuntimeOptions(H5_ROOT, process.env); const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD; const WECHAT_MP_CONFIG = loadWechatMpConfig(); // 无状态前端节点(如 105,MindSpace 经 rclone 挂载)需设 MEMIND_WORKSPACE_MAINTENANCE=0, // 否则启动时对挂载树做 readdir/递归 fs.watch 会占满 libuv 线程池导致 boot 卡死。 const WORKSPACE_MAINTENANCE_ENABLED = process.env.MEMIND_WORKSPACE_MAINTENANCE !== '0'; -const USERS_ROOT = process.env.H5_USERS_ROOT ?? path.join(__dirname, 'users'); +const USERS_ROOT = process.env.H5_USERS_ROOT ?? path.join(H5_ROOT, 'users'); const app = express(); app.set('trust proxy', 1); @@ -330,7 +238,7 @@ const rawUploadBody = express.raw({ type: 'application/octet-stream', limit: Math.max(mindSpaceServerRuntime.maxFileBytes, DEFAULT_IMAGE_UPLOAD_MAX_BYTES), }); -const wikiAuth = createWikiAuth(path.join(resolveMindSpacePublishRoot(__dirname), 'wiki-db')); +const wikiAuth = createWikiAuth(path.join(resolveMindSpacePublishRoot(H5_ROOT), 'wiki-db')); let legacyAuth = null; if (ACCESS_PASSWORD && !isDatabaseConfigured()) { @@ -344,35 +252,17 @@ let sessionAccess = null; let sessionStreamStore = null; let tkmindProxy = null; -async function resolveActiveSessionAccess() { - if (sessionAccess) return sessionAccess; - if (!userAuth) return null; - return createSessionAccess({ userAuth, enabled: isSessionBrokerEnabled() }); -} - -async function ownsAgentSession(userId, sessionId) { - if (!userId || !sessionId) return false; - const access = await resolveActiveSessionAccess(); - if (!access) return false; - return access.validateOwnership(userId, sessionId); -} - -async function unregisterAgentSessionForUser(userId, sessionId) { - if (!userId || !sessionId) return; - const access = await resolveActiveSessionAccess(); - if (!access) return; - await access.unregisterSession({ userId, sessionId }); -} let agentRunGateway = null; -const sessionPageDeliveryLocks = new Map(); -function beginSessionPageDelivery(sessionId) { - sessionPageDeliveryLocks.set(sessionId, Number(sessionPageDeliveryLocks.get(sessionId) ?? 0) + 1); -} -function endSessionPageDelivery(sessionId) { - const remaining = Number(sessionPageDeliveryLocks.get(sessionId) ?? 0) - 1; - if (remaining > 0) sessionPageDeliveryLocks.set(sessionId, remaining); - else sessionPageDeliveryLocks.delete(sessionId); -} +const { + ownsAgentSession, + unregisterAgentSessionForUser, + beginSessionPageDelivery, + endSessionPageDelivery, + isSessionPageDeliveryActive, +} = createPortalSessionCoordinator({ + getSessionAccess: () => sessionAccess, + getUserAuth: () => userAuth, +}); let chatIntentRouter = null; let toolGateway = null; let assetGatewayConfigService = null; @@ -429,535 +319,200 @@ async function bootstrapUserAuth() { try { if (!isDatabaseConfigured()) return false; const pool = createDbPool(); - const mindSearchConfigService = createMindSearchConfigService(pool); - await mindSearchConfigService.ensureSchema(); - authPool = pool; - await initSchema(pool); - await ensureMindSpaceConfig(pool, { - env: process.env, - }); - const storedMindSpaceConfig = await loadMindSpaceConfig(pool, { - env: process.env, - includeAnalyticsSecret: true, - }); - if (storedMindSpaceConfig?.analytics) { - mindSpaceAnalyticsConfig = { - ...mindSpaceAnalyticsConfig, - ...storedMindSpaceConfig.analytics, - enabled: Boolean(storedMindSpaceConfig.analytics.enabled && storedMindSpaceConfig.analytics.websiteId && storedMindSpaceConfig.analytics.idSecret), - hostPath: '/analytics', - scriptPath: '/analytics/script.js', - }; - } - scheduleService = createScheduleService(pool, { - defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', - }); - pageDataService = createPageDataService({ - getUserAuth: () => userAuth, - getPool: () => authPool, - resolveWorkspaceRoot: async (user) => { - if (user?.workspaceRoot) return user.workspaceRoot; - if (!userAuth || !user?.id) return null; - return userAuth.resolveWorkingDir(user.id); - }, - }); - pageDataPublicService = createPageDataPublicService({ - getPool: () => authPool, - resolveH5Root: () => __dirname, - }); - feedbackService = createFeedbackService(pool); - mindSpace = createMindSpaceService(pool, { - maxFileBytes: mindSpaceServerRuntime.maxFileBytes, - aiDailyLimit: mindSpaceServerRuntime.aiDailyLimit, - publicPageLimit: mindSpaceServerRuntime.publicPageLimit, - monthlyViewLimit: mindSpaceServerRuntime.monthlyViewLimit, - scheduleService, - }); - const resolveUserIdForAgentSession = async (sessionId) => { - const [rows] = await pool.query( - `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, - [sessionId], - ); - return rows[0]?.user_id ?? null; - }; - const mindSpaceRuntimeAdapter = assertMindSpaceServerAdapterContract( - createMindSpaceServerAdapter({ - pool, - h5Root: __dirname, - env: process.env, - maxFileBytes: mindSpaceServerRuntime.maxFileBytes, - publicPageLimit: mindSpaceServerRuntime.publicPageLimit, - resolveUserIdForAgentSession, - resolveWorkspaceRoot: (userId) => userAuth.resolveWorkingDir(userId), - resolveSessionSnapshot: (sessionId) => sessionSnapshotService?.get?.(sessionId), - registerPublicHtmlArtifactsForConversation, - syncWorkspaceAssetsEnabled: WORKSPACE_MAINTENANCE_ENABLED, - remote: mindSpaceServerRuntime.remote, - logger: console, - }), - ); - mindSpaceRuntimeAdapter.assertReady?.(); - mindSpaceServiceFacade = mindSpaceRuntimeAdapter.serviceFacade; - mindSpaceConversationPackageRegistry = mindSpaceRuntimeAdapter.conversationPackageRegistry; - mindSpaceAssets = mindSpaceRuntimeAdapter.assetService; - mindSpacePages = mindSpaceRuntimeAdapter.pageService; - mindSpacePageSync = mindSpaceRuntimeAdapter.pageSyncService; - mindSpacePageLiveEdit = mindSpaceRuntimeAdapter.pageLiveEditService; - mindSpaceAssetAgent = mindSpaceRuntimeAdapter.assetAgentService; - mindSpacePublications = mindSpaceRuntimeAdapter.publicationService; - workspacePageDeliver = createWorkspacePageDeliverService({ - pool, - pageService: mindSpacePages, - publicationService: mindSpacePublications, - pageSyncService: mindSpacePageSync, - pageDataEnsure: { ensurePageDataHtmlPagesBound }, - h5Root: __dirname, - storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot, - findPageByRelativePath: mindSpacePages.findPageByRelativePath.bind(mindSpacePages), - logger: console, - }); - const resolveUserIdByDirKey = async (dirKey) => { - let userId = dirKey; - if (!PUBLISH_KEY_UUID.test(dirKey)) { - const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [dirKey]); - userId = rows[0]?.id; - } - return userId ?? null; - }; - await ensureAlgorithmConfig(pool); - const plazaAlgorithmConfig = await loadAlgorithmConfig(pool); - plazaRedis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool); - if (plazaRedis.enabled) { - console.log('Plaza Redis enabled'); - } - plazaSeo = createPlazaSeoService(pool); - plazaInteractions = createPlazaInteractionService(pool, { - formatPostRow, - plazaRedis, - }); - plazaEvents = createPlazaEventService(pool); - plazaRecommend = createPlazaRecommendService(pool, { - eventService: plazaEvents, - formatPostRow, - loadViewerReactions: (viewerId, postIds) => - plazaInteractions.loadViewerReactions(viewerId, postIds), - algorithmConfig: plazaAlgorithmConfig, - }); - plazaPosts = createPlazaPostService(pool, { - loadViewerReactions: (viewerId, postIds) => - plazaInteractions.loadViewerReactions(viewerId, postIds), - plazaRedis, - algorithmConfig: plazaAlgorithmConfig, - recommendService: plazaRecommend, - onPostPublished: (postId) => plazaSeo?.notifyPostPublished(postId), - loadFeaturedPosts: async (viewerId) => { - if (!plazaOps) return { homepage_banner: [], trending: [], category_top: {} }; - return plazaOps.loadActiveFeaturedPosts(viewerId); - }, - }); - plazaOps = createPlazaOpsService(pool, { - formatPostRow, - reviewPost: (...args) => plazaPosts.reviewPost(...args), - invalidateFeedCaches: () => plazaRedis?.invalidateFeedCaches?.(), - }); - startPlazaTasks({ - pool, - plazaRedis, - recalculateHotScores, - writebackPublications, - }); - mindSpaceCleanup = mindSpaceRuntimeAdapter.cleanupService; - await ensurePlanCatalogSchema(pool); - const planCatalogService = createPlanCatalogService(pool); - subscriptionService = createSubscriptionService(pool, { - getPlanAsync: (planType) => planCatalogService.getPlan(planType), - }); - subscriptionService._planCatalogService = planCatalogService; - userAuth = createUserAuth(pool, { - usersRoot: USERS_ROOT, - h5Root: __dirname, - defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500), - subscriptionService, - getMindSearchConfig: () => mindSearchConfigService.getEffectiveConfig(), - provisionUserDataSpace: async ({ userId, workspaceRoot }) => { - const service = createUserDataSpaceService({ workspaceRoot, userId, query: pool }); - return service.ensureReady(); - }, - }); - sessionAccess = createSessionAccess({ userAuth, enabled: isSessionBrokerEnabled() }); - if (sessionAccess.enabled) { - console.log('[Portal] Session Broker enabled (MEMIND_SESSION_BROKER_ENABLED=1)'); - } - const routerDecisionMode = resolveNormalizedRouterDecisionMode(process.env); - const h5SessionFlags = [ - sessionAccess.enabled && 'MEMIND_SESSION_BROKER_ENABLED', - isSessionBrokerMetricsEnabled() && 'MEMIND_SESSION_BROKER_METRICS', - routerDecisionMode !== 'off' && `MEMIND_ROUTER_NORMALIZED_DECISION=${routerDecisionMode}`, - isSessionStreamReplayEnabled() && 'MEMIND_SESSION_STREAM_REPLAY', - ['1', 'true', 'yes', 'on'].includes(String(process.env.MEMIND_SSE_EVENT_TAXONOMY ?? '').trim().toLowerCase()) && 'MEMIND_SSE_EVENT_TAXONOMY', - ['1', 'true', 'yes', 'on'].includes(String(process.env.MEMIND_RUN_STREAM_REPLAY ?? '').trim().toLowerCase()) && 'MEMIND_RUN_STREAM_REPLAY', - ['1', 'true', 'yes', 'on'].includes(String(process.env.MEMIND_H5_HTML_FINISH_GUARD ?? '').trim().toLowerCase()) && 'MEMIND_H5_HTML_FINISH_GUARD', - ].filter(Boolean); - if (h5SessionFlags.length) { - console.log(`[Portal] H5 session flags: ${h5SessionFlags.join(', ')}`); - } - wechatPayClient = createWechatPayClient(loadWechatPayConfig()); - wechatOAuthService = createWechatOAuthService(pool, loadWechatOAuthConfig(), { userAuth }); - rechargeService = createRechargeService(pool, { - userAuth, - wechatPay: wechatPayClient, - }); - if (wechatPayClient.enabled) { - console.log(`WeChat Pay recharge enabled (${wechatPayClient.apiVersion ?? 'unknown'})`); - } - if (wechatOAuthService.enabled) { - console.log('WeChat OAuth login enabled'); - } - mindSpaceAgentJobs = mindSpaceRuntimeAdapter.agentJobService; - // Shared experience store (etat C): retrieval before / recording after each - // agent job, so all instances learn from one another. Gated so it can be - // disabled without touching the runner. Polyglot: when EXPERIENCE_PG_URL is - // set we use PostgreSQL + pgvector (semantic search) for this workload only; - // the MySQL business DB is untouched. Falls back to MySQL keyword store if PG - // init fails (e.g. driver missing) so a misconfig never blocks startup. - let experienceService = null; - if (mindSpaceServerRuntime.experienceEnabled) { - if (process.env.EXPERIENCE_PG_URL) { - try { - const { createPgExperienceService } = await import('./experience-service-pg.mjs'); - experienceService = await createPgExperienceService({ - connectionString: process.env.EXPERIENCE_PG_URL, - }); - console.log('Experience store: PostgreSQL + pgvector'); - } catch (error) { - console.error( - 'Experience PG init failed, falling back to MySQL store:', - error instanceof Error ? error.message : error, - ); - experienceService = createExperienceService(pool); - } - } else { - experienceService = createExperienceService(pool); - } - } - mindSpaceAgentRunner = createMindSpaceAgentRunner({ - apiTarget: API_TARGET, - apiSecret: API_SECRET, - userAuth, - sessionAccess, - agentJobService: mindSpaceAgentJobs, - experienceService, - }); - mindSpaceAudit = createMindSpaceAuditWriter(pool); - // Agent job consumer: a DB-polling worker that atomically claims queued jobs - // (claimNextJob uses SELECT ... FOR UPDATE SKIP LOCKED, so multiple instances - // can run this loop without double-processing) and runs them via the runner. - // Opt-in per instance: must NOT run on the 105 stateless front (see - // docs/g2-load-balancing.md) — gate with MINDSPACE_AGENT_WORKER_ENABLED. - mindSpaceRuntimeAdapter.startBackgroundJobs({ - publicationCleanupIntervalMs: 60 * 1000, - agentWorker: mindSpaceServerRuntime.agentWorker, - agentRunner: mindSpaceAgentRunner, - workspaceMaintenanceEnabled: WORKSPACE_MAINTENANCE_ENABLED, - publishRoot: mindSpaceServerRuntime.publishRoot, - startWorkspaceThumbnailWatcher, - startWorkspaceAssetSyncWatcher, - syncUserWorkspaceByDirKey: async (dirKey, options) => { - const userId = await resolveUserIdByDirKey(dirKey); - if (!userId) return; - await mindSpaceAssets.syncWorkspaceAssets(userId, options); - }, - expireStaleUploadsIntervalMs: 5 * 60 * 1000, - }); - await userAuth.ensureAdminUser(); - const userDataSpaceBackfill = await userAuth.ensureAllUserDataSpaces(); - if (userDataSpaceBackfill.errors.length > 0) { - console.warn( - `[PageData] PG space backfill incomplete: ${userDataSpaceBackfill.errors.length} user(s) failed`, - ); - } - llmProviderService = createLlmProviderService(pool, { - apiTarget: API_TARGET, - apiTargets: API_TARGETS, - apiSecret: API_SECRET, - }); - assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService }); - imageMakeAdminConfigService = createImageMakeAdminConfigService(pool, { - env: process.env, - llmProviderService, - }); - await imageMakeAdminConfigService.ensureSchema(); - let imageMakeClient = null; - try { - imageMakeClient = createImageMakeClientFromEnv(process.env); - } catch (error) { - console.warn('[image_make] invalid local configuration; integration stays disabled:', error?.message ?? error); - } - mindSpaceImageGeneration = createMindSpaceImageGenerationService({ - configService: assetGatewayConfigService, - assetService: mindSpaceAssets, - imageMakeClient, - imageReviewService: createMindSpaceImageReviewService({ - llmProviderService, + const domainServices = + await bootstrapPortalDomainServices({ + pool, + h5Root: H5_ROOT, + env: process.env, + runtime: mindSpaceServerRuntime, + analyticsConfig: mindSpaceAnalyticsConfig, + getUserAuth: () => userAuth, + getSessionSnapshotService: () => + sessionSnapshotService, + onMindSearchSchemaReady: () => { + authPool = pool; + }, + registerPublicHtmlArtifactsForConversation, + workspaceMaintenanceEnabled: + WORKSPACE_MAINTENANCE_ENABLED, + logger: console, + }); + const { + mindSearchConfigService, + mindSpaceRuntimeAdapter, + resolveUserIdByDirKey, + } = domainServices; + mindSpaceAnalyticsConfig = + domainServices.mindSpaceAnalyticsConfig; + scheduleService = domainServices.scheduleService; + pageDataService = domainServices.pageDataService; + pageDataPublicService = + domainServices.pageDataPublicService; + feedbackService = domainServices.feedbackService; + mindSpace = domainServices.mindSpace; + mindSpaceServiceFacade = + domainServices.mindSpaceServiceFacade; + mindSpaceConversationPackageRegistry = + domainServices.mindSpaceConversationPackageRegistry; + mindSpaceAssets = domainServices.mindSpaceAssets; + mindSpacePages = domainServices.mindSpacePages; + mindSpacePageSync = domainServices.mindSpacePageSync; + mindSpacePageLiveEdit = + domainServices.mindSpacePageLiveEdit; + mindSpaceAssetAgent = + domainServices.mindSpaceAssetAgent; + mindSpacePublications = + domainServices.mindSpacePublications; + workspacePageDeliver = + domainServices.workspacePageDeliver; + plazaRedis = domainServices.plazaRedis; + plazaSeo = domainServices.plazaSeo; + plazaInteractions = domainServices.plazaInteractions; + plazaEvents = domainServices.plazaEvents; + plazaRecommend = domainServices.plazaRecommend; + plazaPosts = domainServices.plazaPosts; + plazaOps = domainServices.plazaOps; + mindSpaceCleanup = domainServices.mindSpaceCleanup; + const authServices = + await bootstrapPortalAuthServices({ + pool, + h5Root: H5_ROOT, + usersRoot: USERS_ROOT, + mindSearchConfigService, env: process.env, logger: console, - }), - env: process.env, - logger: console, - }); - wordFilterService = createWordFilterService(pool); - void llmProviderService - .ensureBootstrapRelay() - .then((result) => { - if (result.created) { - console.log(`LLM relay bootstrap created: ${RELAY_BOOTSTRAP.name}`); - } - }) - .catch((err) => { - console.warn('LLM relay bootstrap skipped:', err instanceof Error ? err.message : err); }); - void llmProviderService.syncSelectedToGoosed().catch((err) => { - console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err); - }); - memoryV2ConfigService = createMemoryV2AdminConfigService(pool); - skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root: __dirname }); - agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env }); - wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); - conversationMemoryService = createConversationMemoryService(pool, { - llmProviderService, - getEffectiveEnv: async () => { - const state = await memoryV2ConfigService.getRuntimeState().catch(() => null); - return { ...process.env, ...(state?.overrides ?? {}) }; - }, - }); - memoryV2 = await createManagedMemoryV2Runtime({ - legacyMemoryService: conversationMemoryService, - configService: memoryV2ConfigService, - mysqlPool: pool, - }); - episodicMemoryService = createEpisodicMemoryService(pool, { - getEffectiveEnv: async () => { - const state = await memoryV2ConfigService.getRuntimeState().catch(() => null); - return { ...process.env, ...(state?.overrides ?? {}) }; - }, - logger: console, - }); - await episodicMemoryService.ensureSchema().catch((err) => { - console.warn( - '[episodic-memory] schema setup degraded; snapshot fallback remains available:', - err instanceof Error ? err.message : err, - ); - }); - sessionSnapshotService = createSessionSnapshotService(pool, { - conversationMemoryService, - memoryV2, - episodicMemoryService, - }); - if (isSessionStreamReplayEnabled()) { - sessionStreamStore = createSessionStreamStore({ pool }); - } - directChatService = createDirectChatService({ - userAuth, - sessionAccess, - llmProviderService, - sessionSnapshotService, - memoryV2, - conversationMemoryService, - episodicMemoryService, - }); - chatIntentRouter = createManagedChatIntentRouter({ - llmProviderService, - memoryV2, - conversationMemoryService, - episodicMemoryService, - configService: memoryV2ConfigService, - }); - // GOOSED PROXY BOUNDARY: H5 chat → goosed 唯一入口(Patch 5, goosed-proxy-boundary.mjs) - tkmindProxy = createTkmindProxy({ - apiTarget: API_TARGET, - apiTargets: API_TARGETS, - apiSecret: API_SECRET, - userAuth, - sessionAccess, - sessionStreamStore, - llmProviderService, - subscriptionService, - sessionSnapshotService, - conversationMemoryService, - memoryV2, - localFetchAsset: mindSpaceAssets - ? async (userId, assetId) => { - const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId); - const buffer = await fs.promises.readFile(assetPath); - return { buffer, mimeType: asset.mimeType }; - } - : null, - }); - toolGateway = createToolGateway({ llmProviderService }); - agentRunGateway = createAgentRunGateway({ - pool, - userAuth, - sessionAccess, - tkmindProxy, - toolGateway, - directChatService, - chatIntentRouter, - sessionSnapshotService, - conversationMemoryService, - observePersonalMemoryOnSuccess: async ({ userId, sessionId, userMessage }) => { - if (!memoryV2?.observePersonalMemory) return; - await memoryV2.observePersonalMemory({ - userId, - sessionId, - messages: [userMessage], - }); - }, - syncUserPagesOnSuccess: async ({ userId, sessionId, runStartedAtMs }) => - syncUserGeneratedPages(userId, { sessionId, sinceMs: runStartedAtMs }), - isSessionExternallyBusy: ({ sessionId }) => Number(sessionPageDeliveryLocks.get(sessionId) ?? 0) > 0, - validateRunDeliverables: async ({ userId, deliverables }) => { - const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId }); - const pageDataErrors = []; - for (const page of deliverables?.pages ?? []) { - const relativePath = normalizeWorkspaceRelativePath(page.workspaceRelativePath); - if (!relativePath?.startsWith('public/')) continue; - const filePath = path.resolve(publishDir, relativePath); - if (!filePath.startsWith(`${path.resolve(publishDir)}${path.sep}`) || !fs.existsSync(filePath)) continue; - const html = fs.readFileSync(filePath, 'utf8'); - const evaluation = evaluatePageDataHtmlContent(html, { relativePath }); - if (!evaluation.usesPageDataApi) continue; - for (const issue of evaluation.issues) { - pageDataErrors.push({ code: issue, message: `${relativePath} Page Data HTML 不可交付:${issue}` }); - } - const policy = page.pageId ? readPageAccessPolicy(publishDir, page.pageId) : null; - for (const [dataset, actions] of detectPageDataDatasetUsageFromHtml(html)) { - for (const action of ['read', 'insert']) { - if (actions?.[action] && !policyAllowsAction(policy, dataset, action)) { - pageDataErrors.push({ - code: 'page_data_policy_action_missing', - message: `${relativePath} 的 ${dataset}.${action} 未获最终 policy 授权或 dataset 已关闭`, - }); - } - } - } - } - const violations = scanWorkspaceFilesForProhibitedBrowserStorage({ - publishDir, - relativePaths: (deliverables?.pages ?? []) - .map((page) => page.workspaceRelativePath) - .filter(Boolean), - }); - return { - errors: [...pageDataErrors, ...violations.map((violation) => ({ - code: 'browser_storage_forbidden', - message: `${violation.relativePath} 使用 ${violation.apis.join(', ')}`, - }))], - }; - }, - autoDispatch: ['1', 'true', 'yes', 'on'].includes( - String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(), - ), - maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1), - runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000), - }); - const wechatMp = await loadWechatMpModule(__dirname); - wechatMpService = wechatMp.createWechatMpService({ - config: WECHAT_MP_CONFIG, - userAuth, - sessionAccess, - pageDataFinishGuard: authPool - ? { - pool: authPool, - h5Root: __dirname, - storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot, - } - : null, - apiFetch: tkmindProxy.apiFetch, - startAgentSession: ({ userId, workingDir, sessionPolicy }) => - tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }), - sessionApiFetch: async (sessionId, pathname, init) => { - const target = await tkmindProxy.resolveTarget(sessionId); - return tkmindProxy.apiFetchTo(target, pathname, init); - }, - submitSessionReply: ({ userId, sessionId, requestId, userMessage, options }) => - tkmindProxy.submitSessionReplyForUser(userId, sessionId, requestId, userMessage, options), - scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null, - wechatScheduleLlmConfigService, - llmProviderService, - chatIntentRouter, - sessionIntentClassifier: ({ text }) => chatIntentRouter?.classifySessionAction({ text }), - onPageGenerated: async ({ userId, sessionId, artifacts = [] }) => { - for (const artifact of artifacts) { - void sendMindSpaceAnalyticsEvent({ - config: mindSpaceAnalyticsConfig, - eventName: 'page_generated', - ownerId: userId, - ownerSegment: resolveAnalyticsOwnerSegment(await userAuth?.getUserById(userId).catch(() => null) ?? {}), - ownerLabel: resolveAnalyticsOwnerLabel(await userAuth?.getUserById(userId).catch(() => null) ?? {}), - pageId: artifact.relativePath, - publicationId: sessionId, - agentRunId: sessionId, - channel: 'wechat_mp', - url: artifact.url || artifact.relativePath || '/', - }); - } - }, - applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId), - refreshSessionSnapshot: - sessionSnapshotService?.isEnabled() - ? (sessionId, userId) => - sessionSnapshotService.refresh(sessionId, userId, async (pathname, init) => { - const target = await tkmindProxy.resolveTarget(sessionId); - return tkmindProxy.apiFetchTo(target, pathname, init); - }) - : null, - }); - notificationDispatcher = createNotificationDispatcher({ - sendWechatTextToUser: wechatMpService?.enabled - ? (userId, text) => wechatMpService.sendTextToUser(userId, text) - : null, - }); - userAuth.setRechargeNotifier(async ({ userId, title, body, dedupeKey }) => { - await notificationDispatcher.sendRechargeSuccess({ userId, title, body, dedupeKey }); - }); - if ( - process.env.H5_REMINDER_WORKER_ENABLED === '1' && - wechatMpService?.enabled && - scheduleService - ) { - scheduleReminderWorker = startScheduleReminderWorker({ + subscriptionService = + authServices.subscriptionService; + userAuth = authServices.userAuth; + sessionAccess = authServices.sessionAccess; + wechatPayClient = authServices.wechatPayClient; + wechatOAuthService = + authServices.wechatOAuthService; + rechargeService = authServices.rechargeService; + const agentServices = + await bootstrapPortalAgentServices({ + pool, + env: process.env, + runtime: mindSpaceServerRuntime, + apiTarget: API_TARGET, + apiTargets: API_TARGETS, + apiSecret: API_SECRET, + userAuth, + sessionAccess, + mindSpaceRuntimeAdapter, + mindSpaceAssets, + resolveUserIdByDirKey, + workspaceMaintenanceEnabled: + WORKSPACE_MAINTENANCE_ENABLED, + startWorkspaceThumbnailWatcher, + startWorkspaceAssetSyncWatcher, + logger: console, + }); + mindSpaceAgentJobs = + agentServices.mindSpaceAgentJobs; + mindSpaceAgentRunner = + agentServices.mindSpaceAgentRunner; + mindSpaceAudit = agentServices.mindSpaceAudit; + llmProviderService = + agentServices.llmProviderService; + assetGatewayConfigService = + agentServices.assetGatewayConfigService; + imageMakeAdminConfigService = + agentServices.imageMakeAdminConfigService; + mindSpaceImageGeneration = + agentServices.mindSpaceImageGeneration; + wordFilterService = + agentServices.wordFilterService; + const memorySessionServices = + await bootstrapPortalMemorySessionServices({ + pool, + h5Root: H5_ROOT, + env: process.env, + llmProviderService, + userAuth, + sessionAccess, + logger: console, + }); + memoryV2ConfigService = + memorySessionServices.memoryV2ConfigService; + skillRuntimeConfigService = + memorySessionServices.skillRuntimeConfigService; + agentCodeRunPolicyService = + memorySessionServices.agentCodeRunPolicyService; + wechatScheduleLlmConfigService = + memorySessionServices.wechatScheduleLlmConfigService; + conversationMemoryService = + memorySessionServices.conversationMemoryService; + memoryV2 = memorySessionServices.memoryV2; + episodicMemoryService = + memorySessionServices.episodicMemoryService; + sessionSnapshotService = + memorySessionServices.sessionSnapshotService; + sessionStreamStore = + memorySessionServices.sessionStreamStore; + directChatService = + memorySessionServices.directChatService; + chatIntentRouter = + memorySessionServices.chatIntentRouter; + const gatewayServices = + bootstrapPortalGatewayServices({ + pool, + h5Root: H5_ROOT, + env: process.env, + apiTarget: API_TARGET, + apiTargets: API_TARGETS, + apiSecret: API_SECRET, + userAuth, + sessionAccess, + sessionStreamStore, + llmProviderService, + subscriptionService, + sessionSnapshotService, + conversationMemoryService, + memoryV2, + mindSpaceAssets, + directChatService, + chatIntentRouter, + syncUserGeneratedPages, + isSessionPageDeliveryActive, + }); + tkmindProxy = gatewayServices.tkmindProxy; + toolGateway = gatewayServices.toolGateway; + agentRunGateway = + gatewayServices.agentRunGateway; + const integrationServices = + await bootstrapPortalIntegrationServices({ + pool, + h5Root: H5_ROOT, + env: process.env, + usersRoot: USERS_ROOT, + wechatMpConfig: WECHAT_MP_CONFIG, + authPool, + userAuth, + sessionAccess, + tkmindProxy, scheduleService, - notificationDispatcher, + wechatScheduleLlmConfigService, + llmProviderService, + chatIntentRouter, + sessionSnapshotService, + mindSpaceAnalyticsConfig, + subscriptionService, + apiTarget: API_TARGET, + apiSecret: API_SECRET, + mindSpacePages, + mindSpacePageLiveEdit, + logger: console, }); - console.log('Schedule reminder worker enabled'); - } - if (subscriptionService) { - const subExpiryTimer = setInterval(async () => { - try { - const { renewed, failed } = await subscriptionService.processAutoRenewals(); - if (renewed > 0) console.log(`Auto-renewed ${renewed} subscription(s)`); - if (failed > 0) console.log(`Auto-renew failed for ${failed} subscription(s) (balance insufficient)`); - const n = await subscriptionService.expireStaleSubscriptions(); - if (n > 0) console.log(`Expired ${n} stale subscription(s)`); - } catch (err) { - console.warn('Subscription expiry check failed:', err); - } - }, 60 * 60 * 1000); // hourly - subExpiryTimer.unref?.(); - } - mindSpacePageEditSession = createPageEditSessionService({ - apiTarget: API_TARGET, - apiSecret: API_SECRET, - userAuth, - sessionAccess, - pageService: mindSpacePages, - pageLiveEdit: mindSpacePageLiveEdit, - llmProviderService, - }); - if (wechatMpService?.enabled) { - console.log('WeChat MP webhook enabled'); - } - console.log(`User auth enabled (MySQL), workspace root: ${USERS_ROOT}`); + wechatMpService = + integrationServices.wechatMpService; + notificationDispatcher = + integrationServices.notificationDispatcher; + scheduleReminderWorker = + integrationServices.scheduleReminderWorker; + mindSpacePageEditSession = + integrationServices.mindSpacePageEditSession; return true; } catch (err) { console.error('User auth bootstrap failed:', err); @@ -973,45 +528,18 @@ async function bootstrapUserAuth() { const userAuthReady = bootstrapUserAuth(); -function legacySessionToken(req) { - return parseCookies(req.get('cookie'))[AUTH_COOKIE]; -} - -function userToken(req) { - return parseCookies(req.get('cookie'))[USER_COOKIE]; -} - -function setUserLoginCookies(res, req, token) { - res.set( - 'Set-Cookie', - userLoginCookies(token, isSecureRequest(req), resolveCookieDomainForRequest(req)), - ); -} - -function clearUserLoginCookies(res, req) { - const secure = isSecureRequest(req); - const domain = resolveCookieDomainForRequest(req); - const cookies = clearUserLogoutCookies(secure, domain); - if (legacyAuth) { - cookies.push(clearSessionCookie(secure)); - } - res.set('Set-Cookie', cookies); -} - -async function attachUserSession(req, _res, next) { - if (!userAuth) return next(); - const token = userToken(req); - req.userToken = token; - try { - req.userSession = token ? await userAuth.verify(token) : null; - req.userSessionError = null; - } catch (err) { - req.userSession = null; - req.userSessionError = err; - console.error('[Auth] session verify failed:', err instanceof Error ? err.message : err); - } - next(); -} +const { + legacySessionToken, + userToken, + setUserLoginCookies, + clearUserLoginCookies, + attachUserSession, +} = createPortalAuthSessionHelpers({ + isSecureRequest, + getLegacyAuth: () => legacyAuth, + getUserAuth: () => userAuth, + logger: console, +}); app.use(attachUserSession); @@ -1037,904 +565,73 @@ async function resolveAgentCodeRunForClient(userId) { } } -app.get('/auth/status', async (req, res) => { - await userAuthReady; - if (userAuth) { - try { - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.json({ authenticated: false, mode: 'user' }); - const row = await userAuth.getUserById(me.id); - const capabilityState = await userAuth.resolveUserCapabilities(row); - const skillRuntime = await resolveSkillRuntimeForClient(); - const agentCodeRun = await resolveAgentCodeRunForClient(me.id); - return res.json({ - authenticated: true, - user: me, - mode: 'user', - capabilities: capabilityState.capabilities, - grantedSkills: capabilityState.grantedSkills ?? [], - unrestricted: capabilityState.unrestricted, - skillRuntime, - agentCodeRun, - }); - } catch (err) { - console.error('[Auth] status failed:', err instanceof Error ? err.message : err); - return res.status(503).json({ - authenticated: false, - mode: 'unavailable', - message: '用户认证服务不可用,请稍后重试', - }); - } - } - if (isDatabaseConfigured()) { - return res.status(503).json({ - authenticated: false, - mode: 'unavailable', - message: '用户认证服务不可用,请稍后重试', - }); - } - if (legacyAuth) { - return res.json({ - authenticated: legacyAuth.verify(legacySessionToken(req)), - mode: 'legacy', - }); - } - return res.json({ authenticated: false, mode: 'none' }); +attachPortalCoreAuthRoutes({ + app, + jsonBody, + userAuthReady, + getUserAuth: () => userAuth, + getLegacyAuth: () => legacyAuth, + userToken, + legacySessionToken, + setUserLoginCookies, + isSecureRequest, + resolveSkillRuntimeForClient, + resolveAgentCodeRunForClient, + getPlazaSeo: () => plazaSeo, + plazaClientIp, + logger: console, }); -app.post('/auth/login', jsonBody, async (req, res) => { - await userAuthReady; - const secure = isSecureRequest(req); - - if (userAuth) { - const { username, password } = req.body ?? {}; - if (!username || !password) { - return res.status(400).json({ message: '用户名和密码不能为空' }); - } - const result = await userAuth.login({ username, password, ip: req.ip }); - if (!result.ok) { - if (result.retryAfterMs > 0) { - res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))); - return res.status(429).json({ message: result.message }); - } - return res.status(401).json({ message: result.message }); - } - setUserLoginCookies(res, req, result.token); - return res.json({ - authenticated: true, - user: result.user, - mode: 'user', - sessionToken: result.token, - }); - } - - if (!legacyAuth) { - return res.status(503).json({ message: '未配置用户数据库或访问密码' }); - } - - const password = typeof req.body?.password === 'string' ? req.body.password : ''; - const result = legacyAuth.login(password, req.ip); - if (!result.ok) { - if (result.retryAfterMs > 0) { - res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))); - return res.status(429).json({ message: '尝试次数过多,请稍后再试' }); - } - return res.status(401).json({ message: '密码错误,请重试' }); - } - res.set('Set-Cookie', sessionCookie(result.token, secure)); - return res.json({ authenticated: true, mode: 'legacy' }); +attachPortalWechatAuthRoutes({ + app, + jsonBody, + userAuthReady, + getUserAuth: () => userAuth, + getWechatOAuthService: () => wechatOAuthService, + getWechatMpService: () => wechatMpService, + wechatMpConfig: WECHAT_MP_CONFIG, + userToken, + setUserLoginCookies, + isSecureRequest, + getPlazaSeo: () => plazaSeo, + plazaClientIp, + logger: console, }); -app.post('/auth/wechat-miniapp/login', jsonBody, async (req, res) => { - await userAuthReady; - if (!userAuth) { - return res.status(503).json({ message: '未启用用户系统' }); - } - const miniappConfig = loadWechatMiniappConfig(); - if (!miniappConfig.enabled) { - return res.status(503).json({ message: '小程序登录未配置,请联系管理员' }); - } - const code = typeof req.body?.code === 'string' ? req.body.code.trim() : ''; - if (!code) { - return res.status(400).json({ message: '缺少微信登录 code' }); - } - try { - const session = await exchangeMiniProgramCode({ - appId: miniappConfig.appId, - appSecret: miniappConfig.appSecret, - code, - }); - const result = await userAuth.loginByWechatMiniProgram({ - appId: miniappConfig.appId, - openid: session.openid, - unionid: session.unionid, - }); - if (!result.ok) { - return res.status(401).json({ message: result.message || '微信登录失败' }); - } - setUserLoginCookies(res, req, result.token); - return res.json({ - authenticated: true, - user: result.user, - mode: 'user', - isNewUser: Boolean(result.isNewUser), - sessionToken: result.token, - }); - } catch (err) { - const message = err instanceof Error ? err.message : '微信登录失败'; - const status = err?.code === 'wechat_miniapp_code_failed' ? 401 : 400; - return res.status(status).json({ message }); - } +attachPortalAccountFeedbackRoutes({ + app, + jsonBody, + userAuthReady, + getUserAuth: () => userAuth, + getLegacyAuth: () => legacyAuth, + getSubscriptionService: () => subscriptionService, + getFeedbackService: () => feedbackService, + userToken, + legacySessionToken, + clearUserLoginCookies, + resolveSkillRuntimeForClient, + resolveAgentCodeRunForClient, + logger: console, }); -app.post('/auth/register', jsonBody, async (req, res) => { - await userAuthReady; - if (!userAuth) { - return res.status(503).json({ message: '未启用用户注册' }); - } - const { username, password, displayName, email } = req.body ?? {}; - const result = await userAuth.register({ username, password, displayName, email }); - if (!result.ok) { - const status = result.message.includes('已存在') ? 409 : 400; - return res.status(status).json({ message: result.message }); - } - if (plazaSeo && req.body?.utm_source) { - void plazaSeo - .recordAttribution( - { - event_type: 'signup', - utm_source: req.body.utm_source, - utm_medium: req.body.utm_medium, - utm_campaign: req.body.utm_campaign, - ref_id: req.body.ref ?? req.body.ref_id, - user_id: result.user?.id ?? null, - }, - plazaClientIp(req), - ) - .catch(() => {}); - } - return res.json({ ok: true, user: result.user }); +attachPortalNotificationRoutes({ + app, + userAuthReady, + getUserAuth: () => userAuth, + getScheduleService: () => scheduleService, + userToken, + logger: console, }); -app.post('/auth/reset-password', jsonBody, async (req, res) => { - await userAuthReady; - if (!userAuth) { - return res.status(503).json({ message: '未启用用户系统' }); - } - const { username, email, password } = req.body ?? {}; - const result = await userAuth.resetPassword({ username, email, password }); - if (!result.ok) { - return res.status(400).json({ message: result.message }); - } - return res.json({ ok: true }); -}); - -app.get('/auth/wechat/config', async (req, res) => { - await userAuthReady; - if (!wechatOAuthService?.enabled) { - return res.json({ - enabled: false, - inWechat: isWechatUserAgent(req.get('user-agent') || ''), - scanEnabled: false, - }); - } - return res.json(wechatOAuthService.publicConfig(req)); -}); - -app.get('/auth/wechat/js-sdk-signature', async (req, res) => { - await userAuthReady; - if (!userAuth || !wechatMpService?.enabled) { - return res.status(503).json({ message: '微信 JS-SDK 未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - - const pageUrl = String(req.query?.url ?? '').split('#')[0]; - if (!pageUrl) { - return res.status(400).json({ message: '缺少 url' }); - } - try { - const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, { - publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl, - requestHost: req.get('x-forwarded-host') || req.get('host') || '', - }); - const payload = await wechatMpService.createJsSdkSignature(normalizedUrl); - return res.json(payload); - } catch (err) { - const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败'; - console.warn('WeChat JS-SDK signature failed:', message); - return res.status(502).json({ message }); - } -}); - -app.get('/auth/wechat/public-js-sdk-signature', async (req, res) => { - await userAuthReady; - if (!wechatMpService?.enabled) { - return res.status(503).json({ message: '微信 JS-SDK 未启用' }); - } - const pageUrl = String(req.query?.url ?? '').split('#')[0]; - if (!pageUrl) { - return res.status(400).json({ message: '缺少 url' }); - } - try { - const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, { - publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl, - requestHost: req.get('x-forwarded-host') || req.get('host') || '', - }); - const payload = await wechatMpService.createJsSdkSignature(normalizedUrl); - return res.json(payload); - } catch (err) { - const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败'; - console.warn('WeChat public JS-SDK signature failed:', message); - return res.status(502).json({ message }); - } -}); - -app.get('/auth/wechat/status', async (req, res) => { - await userAuthReady; - if (!userAuth || !wechatOAuthService?.enabled) { - return res.json({ enabled: false, bound: false }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const config = loadWechatOAuthConfig(); - const status = await userAuth.getWechatBindingStatus(me.id, config.appId); - return res.json({ enabled: true, ...status }); -}); - -if (WECHAT_MP_CONFIG.enabled) { - app.get('/auth/wechat/agent-route', async (req, res) => { - await userAuthReady; - if (!userAuth || !wechatMpService?.enabled) { - return res.status(503).json({ message: '公众号 Agent 未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - return res.json(await wechatMpService.getRouteStatusForUser(me.id)); - }); - - app.post('/auth/wechat/agent-route/reset', async (req, res) => { - await userAuthReady; - if (!userAuth || !wechatMpService?.enabled) { - return res.status(503).json({ message: '公众号 Agent 未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - try { - const result = await wechatMpService.recreateRouteForUser(me.id); - if (!result.ok) return res.status(400).json({ message: result.message }); - return res.json(result); - } catch (err) { - return res.status(500).json({ - message: err instanceof Error ? err.message : '重建公众号 Agent 路由失败', - }); - } - }); -} - -app.get('/auth/wechat/pending/:token', async (req, res) => { - await userAuthReady; - if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); - const pending = await userAuth.getWechatPendingBind(req.params.token); - if (!pending) return res.status(404).json({ message: '绑定会话已过期,请重新微信登录' }); - return res.json({ - nickname: pending.nickname, - avatarUrl: pending.avatar_url, - returnTo: pending.return_to || '/', - }); -}); - -app.post('/auth/wechat/register', jsonBody, async (req, res) => { - await userAuthReady; - const secure = isSecureRequest(req); - if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); - const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : ''; - if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' }); - const result = await userAuth.completeWechatRegister({ pendingToken }); - if (!result.ok) return res.status(400).json({ message: result.message }); - if (result.isNewUser && plazaSeo) { - void plazaSeo - .recordAttribution( - { - event_type: 'signup', - utm_source: result.utmSource || 'wechat', - utm_medium: result.utmMedium, - utm_campaign: result.utmCampaign, - user_id: result.user?.id ?? null, - }, - plazaClientIp(req), - ) - .catch(() => {}); - } - setUserLoginCookies(res, req, result.token); - return res.json({ - authenticated: true, - user: result.user, - returnTo: result.returnTo || '/', - }); -}); - -app.post('/auth/wechat/bind', jsonBody, async (req, res) => { - await userAuthReady; - const secure = isSecureRequest(req); - if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); - const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : ''; - const username = typeof req.body?.username === 'string' ? req.body.username : ''; - const password = typeof req.body?.password === 'string' ? req.body.password : ''; - if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' }); - if (!username || !password) { - return res.status(400).json({ message: '用户名和密码不能为空' }); - } - const result = await userAuth.completeWechatBindAccount({ - pendingToken, - username, - password, - ip: req.ip, - }); - if (!result.ok) { - const status = result.retryAfterMs > 0 ? 429 : 401; - if (result.retryAfterMs > 0) { - res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))); - } - return res.status(status).json({ message: result.message }); - } - setUserLoginCookies(res, req, result.token); - return res.json({ - authenticated: true, - user: result.user, - bound: true, - returnTo: result.returnTo || '/', - }); -}); - -app.post('/auth/wechat/scan/start', async (req, res) => { - await userAuthReady; - if (!wechatOAuthService?.enabled) { - return res.status(503).json({ message: '微信登录未启用' }); - } - try { - const payload = await wechatOAuthService.startScanLogin(req); - return res.json(payload); - } catch (err) { - return res.status(503).json({ - message: err instanceof Error ? err.message : '微信扫码登录不可用', - }); - } -}); - -app.get('/auth/wechat/scan/poll', async (req, res) => { - await userAuthReady; - if (!wechatOAuthService?.enabled) { - return res.status(503).json({ message: '微信登录未启用' }); - } - const state = typeof req.query?.state === 'string' ? req.query.state : ''; - if (!state) return res.status(400).json({ message: '缺少扫码状态' }); - const result = await wechatOAuthService.pollScanLogin(state); - if (result.status === 'complete' && result.token) { - setUserLoginCookies(res, req, result.token); - } - return res.json(result); -}); - -app.get('/auth/wechat/authorize', async (req, res) => { - await userAuthReady; - if (!wechatOAuthService?.enabled) { - return res.status(503).json({ message: '微信登录未启用' }); - } - try { - let bindUserId = null; - const intent = typeof req.query?.intent === 'string' ? req.query.intent.trim().toLowerCase() : 'login'; - if (intent === 'bind' && userAuth) { - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '请先登录后再绑定微信' }); - bindUserId = me.id; - } - const redirectUrl = await wechatOAuthService.buildAuthorizeRedirect(req, { bindUserId }); - return res.redirect(302, redirectUrl); - } catch (err) { - console.error('WeChat authorize failed:', err); - return res.status(500).json({ message: err instanceof Error ? err.message : '微信授权失败' }); - } -}); - -app.get('/auth/wechat/callback', async (req, res) => { - await userAuthReady; - const secure = isSecureRequest(req); - if (!wechatOAuthService?.enabled || !userAuth) { - return res.redirect(302, '/?wechat_error=unavailable'); - } - try { - const result = await wechatOAuthService.handleCallback({ - code: typeof req.query?.code === 'string' ? req.query.code : '', - state: typeof req.query?.state === 'string' ? req.query.state : '', - ip: req.ip, - }); - - if (result.action === 'binding_gate') { - const params = new URLSearchParams(); - params.set('wechat_pending', result.pendingToken); - if (result.returnTo && result.returnTo !== '/') { - params.set('return_to', result.returnTo); - } - return res.redirect(302, `/?${params.toString()}`); - } - - if (result.action === 'poll_error') { - return res.redirect( - 302, - `/?wechat_error=${encodeURIComponent(result.message || '微信登录失败')}`, - ); - } - - if (result.isNewUser && plazaSeo) { - void plazaSeo - .recordAttribution( - { - event_type: 'signup', - utm_source: result.utmSource || 'wechat', - utm_medium: result.utmMedium, - utm_campaign: result.utmCampaign, - user_id: result.user?.id ?? null, - }, - plazaClientIp(req), - ) - .catch(() => {}); - } - - if (result.authMode === 'open' || result.authMode === 'scan') { - return res.send(`微信登录

扫码登录成功,请返回电脑继续操作。

`); - } - - setUserLoginCookies(res, req, result.token); - return res.redirect(302, result.returnTo || '/'); - } catch (err) { - console.error('WeChat callback failed:', err); - const message = encodeURIComponent(err instanceof Error ? err.message : '微信登录失败'); - return res.redirect(302, `/?wechat_error=${message}`); - } -}); - -app.get('/auth/me', async (req, res) => { - await userAuthReady; - if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const [paths, capabilityState, subscription, skillRuntime] = await Promise.all([ - userAuth.listPathGrants(me.id), - userAuth.resolveUserCapabilities(await userAuth.getUserById(me.id)), - subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null, - resolveSkillRuntimeForClient(), - ]); - return res.json({ - user: { ...me, subscription }, - paths, - capabilities: capabilityState.capabilities, - grantedSkills: capabilityState.grantedSkills ?? [], - unrestricted: capabilityState.unrestricted, - skillRuntime, - }); -}); - -app.post('/auth/logout', async (req, res) => { - await userAuthReady; - if (userAuth) { - await userAuth.revoke(userToken(req)); - } - if (legacyAuth) { - legacyAuth.revoke(legacySessionToken(req)); - } - if (userAuth || legacyAuth) { - clearUserLoginCookies(res, req); - } - res.status(204).end(); -}); - - -app.get('/auth/usage', async (req, res) => { - await userAuthReady; - if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const records = await userAuth.listUsageRecords({ userId: me.id, limit: 30 }); - res.json({ records }); -}); - -app.post('/auth/feedback', jsonBody, async (req, res) => { - await userAuthReady; - if (!userAuth || !feedbackService) { - return res.status(503).json({ message: '反馈服务未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - try { - const result = await feedbackService.submit(me.id, { - type: req.body?.type, - title: req.body?.title, - description: req.body?.description, - contact: req.body?.contact, - images: req.body?.images, - context: req.body?.context, - }); - res.status(201).json({ feedback: result }); - } catch (err) { - const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : ''; - if (code === 'invalid_input') { - return res.status(400).json({ message: err instanceof Error ? err.message : '提交内容无效' }); - } - console.warn('Submit feedback failed:', err instanceof Error ? err.message : err); - return res.status(500).json({ message: '反馈提交失败,请稍后重试' }); - } -}); - -app.get('/auth/feedback', async (req, res) => { - await userAuthReady; - if (!userAuth || !feedbackService) { - return res.status(503).json({ message: '反馈服务未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const limit = Math.min(Math.max(Number(req.query?.limit) || 20, 1), 50); - const items = await feedbackService.listForUser(me.id, { limit }); - res.json({ items }); -}); - -app.get('/auth/feedback/board', async (req, res) => { - await userAuthReady; - if (!userAuth || !feedbackService) { - return res.status(503).json({ message: '反馈服务未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const page = Math.max(Number(req.query?.page) || 1, 1); - const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 10); - try { - const result = await feedbackService.listAll({ page, limit }); - res.json(result); - } catch (err) { - console.warn('List feedback board failed:', err instanceof Error ? err.message : err); - return res.status(500).json({ message: '反馈列表加载失败' }); - } -}); - -app.get('/auth/feedback/:feedbackId', async (req, res) => { - await userAuthReady; - if (!userAuth || !feedbackService) { - return res.status(503).json({ message: '反馈服务未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - try { - const item = await feedbackService.getById(req.params.feedbackId, me.id); - res.json({ item, isMine: item.userId === me.id }); - } catch (err) { - const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : ''; - if (code === 'feedback_not_found') { - return res.status(404).json({ message: err instanceof Error ? err.message : '反馈不存在' }); - } - console.warn('Get feedback detail failed:', err instanceof Error ? err.message : err); - return res.status(500).json({ message: '反馈详情加载失败' }); - } -}); - -app.get('/auth/notifications', async (req, res) => { - await userAuthReady; - if (!userAuth || !scheduleService) { - return res.status(503).json({ message: '通知服务未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const rawStatus = typeof req.query?.status === 'string' ? req.query.status : 'unread'; - const status = ['all', 'unread', 'read'].includes(rawStatus) ? rawStatus : 'all'; - const limit = Math.min(Math.max(Number(req.query?.limit) || 20, 1), 100); - try { - const notifications = await scheduleService.listUserNotifications({ userId: me.id, status, limit }); - res.json({ notifications }); - } catch (err) { - console.warn('List user notifications failed:', err instanceof Error ? err.message : err); - res.status(500).json({ message: '通知列表加载失败' }); - } -}); - -app.get('/auth/notifications/events', async (req, res) => { - await userAuthReady; - if (!userAuth || !scheduleService) { - return res.status(503).json({ message: '通知服务未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - - res.status(200); - res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); - res.setHeader('Cache-Control', 'no-cache, no-transform'); - res.setHeader('Connection', 'keep-alive'); - res.flushHeaders?.(); - - let closed = false; - let lastNotificationId = null; - - const sendEvent = (event, data) => { - if (closed || res.destroyed) return; - res.write(`event: ${event}\n`); - res.write(`data: ${JSON.stringify(data)}\n\n`); - }; - - const checkUnread = async () => { - if (closed) return; - try { - const notifications = await scheduleService.listUserNotifications({ - userId: me.id, - status: 'unread', - limit: 1, - }); - const latest = notifications[0] ?? null; - const nextId = latest?.id ?? null; - if (nextId && nextId !== lastNotificationId) { - lastNotificationId = nextId; - sendEvent('notification', { notification: latest }); - } else if (!nextId) { - lastNotificationId = null; - } - } catch { - sendEvent('sync', { reason: 'check_failed' }); - } - }; - - sendEvent('ready', { ok: true }); - await checkUnread(); - - const checkTimer = setInterval(() => { - void checkUnread(); - }, 2500); - const keepaliveTimer = setInterval(() => { - sendEvent('ping', { at: Date.now() }); - }, 25000); - - req.on('close', () => { - closed = true; - clearInterval(checkTimer); - clearInterval(keepaliveTimer); - }); -}); - -app.post('/auth/notifications/:id/read', async (req, res) => { - await userAuthReady; - if (!userAuth || !scheduleService) { - return res.status(503).json({ message: '通知服务未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const ok = await scheduleService.markUserNotificationRead({ - userId: me.id, - notificationId: req.params.id, - }); - if (!ok) return res.status(404).json({ message: '通知不存在或已读' }); - res.json({ ok: true }); -}); - -app.post('/auth/notifications/read-all', async (req, res) => { - await userAuthReady; - if (!userAuth || !scheduleService) { - return res.status(503).json({ message: '通知服务未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const updated = await scheduleService.markAllUserNotificationsRead({ userId: me.id }); - res.json({ ok: true, updated }); -}); - -app.delete('/auth/notifications/:id', async (req, res) => { - await userAuthReady; - if (!userAuth || !scheduleService) { - return res.status(503).json({ message: '通知服务未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const ok = await scheduleService.deleteUserNotification({ - userId: me.id, - notificationId: req.params.id, - }); - if (!ok) return res.status(404).json({ message: '通知不存在' }); - res.status(204).end(); -}); - -app.delete('/auth/notifications', async (req, res) => { - await userAuthReady; - if (!userAuth || !scheduleService) { - return res.status(503).json({ message: '通知服务未启用' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const status = typeof req.query?.status === 'string' ? req.query.status : 'all'; - const deleted = await scheduleService.clearUserNotifications({ userId: me.id, status }); - res.json({ ok: true, deleted }); -}); - -app.get('/auth/billing/ledger', async (req, res) => { - await userAuthReady; - if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const limit = Math.min(Math.max(Number(req.query?.limit) || 30, 1), 100); - const entries = await userAuth.listBillingLedger({ - userId: me.id, - limit, - types: ['recharge', 'adjust', 'refund'], - }); - res.json({ entries }); -}); - -app.get('/auth/billing/config', async (req, res) => { - await userAuthReady; - if (!userAuth || !rechargeService) { - return res.status(503).json({ message: '未启用计费系统' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const [config, sub] = await Promise.all([ - rechargeService.getBillingConfig(me.id), - subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null, - ]); - return res.json({ ...config, subscription: sub }); -}); - -app.get('/auth/billing/subscription', async (req, res) => { - await userAuthReady; - if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const sub = subscriptionService - ? await subscriptionService.getActiveSubscription(me.id) - : null; - const planCatalog = subscriptionService?._planCatalogService; - const plans = (!sub && planCatalog) - ? await planCatalog.listPlans({ includeInactive: false }) - : sub ? undefined : PLAN_CATALOG; - return res.json({ subscription: sub, plans }); -}); - -app.get('/auth/billing/plans', async (req, res) => { - await userAuthReady; - if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const planCatalog = subscriptionService?._planCatalogService; - const plans = planCatalog - ? (await planCatalog.listPlans({ includeInactive: false })) - .filter((p) => p.priceCents > 0) - .map((p) => ({ key: p.planType, ...p })) - : Object.entries(PLAN_CATALOG) - .filter(([, plan]) => plan.priceCents > 0) - .map(([key, plan]) => ({ key, ...plan })); - const [sub, wallet] = await Promise.all([ - subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null, - userAuth.getUserById(me.id), - ]); - return res.json({ - plans, - subscription: sub, - balanceCents: wallet ? Number(wallet.balance_cents ?? 0) : 0, - }); -}); - -app.post('/auth/billing/subscribe', jsonBody, async (req, res) => { - await userAuthReady; - if (!userAuth || !subscriptionService) { - return res.status(503).json({ message: '未启用订阅系统' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - if (me.status === 'disabled') return res.status(403).json({ message: '账户已禁用' }); - const { planType, autoRenew = false } = req.body ?? {}; - if (!planType || typeof planType !== 'string') { - return res.status(400).json({ message: '请选择套餐' }); - } - const result = await subscriptionService.purchaseSubscription(me.id, planType, Boolean(autoRenew)); - if (!result.ok) { - if (result.code === 'INSUFFICIENT_BALANCE') { - return res.status(402).json({ - message: result.message, - code: result.code, - balanceCents: result.balanceCents, - requiredCents: result.requiredCents, - shortfallCents: result.shortfallCents, - }); - } - if (result.code === 'DOWNGRADE_NOT_ALLOWED') { - return res.status(409).json({ - message: result.message, - code: result.code, - currentPlanType: result.currentPlanType, - }); - } - return res.status(400).json({ message: result.message }); - } - return res.json({ subscription: result.subscription, balanceCents: result.balanceCents }); -}); - -app.post('/auth/billing/space-purchase', jsonBody, async (req, res) => { - await userAuthReady; - if (!userAuth || !mindSpace) { - return res.status(503).json({ message: '未启用空间系统' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const sizeMb = Number(req.body?.sizeMb); - const result = await userAuth.purchaseSpaceQuota(me.id, sizeMb); - if (!result.ok) { - if (result.code === 'INSUFFICIENT_BALANCE') { - return res.status(402).json({ - message: result.message, - code: result.code, - details: { - code: 'INSUFFICIENT_BALANCE', - balanceCents: result.balanceCents, - minRechargeCents: result.minRechargeCents, - suggestedTiers: result.suggestedTiers, - }, - }); - } - return res.status(400).json({ message: result.message }); - } - const quota = await mindSpace.getQuota(me.id); - return res.json({ - quota: quota ?? result.quota, - balanceCents: result.balanceCents, - purchasedMb: sizeMb, - costCents: sizeMb * 200, - }); -}); - -app.post('/auth/billing/auto-renew', jsonBody, async (req, res) => { - await userAuthReady; - if (!userAuth || !subscriptionService) { - return res.status(503).json({ message: '未启用订阅系统' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const { enabled } = req.body ?? {}; - if (typeof enabled !== 'boolean') { - return res.status(400).json({ message: '请传入 enabled: true/false' }); - } - const result = await subscriptionService.setAutoRenew(me.id, enabled); - return res.json(result); -}); - -app.post('/auth/billing/recharge-orders', jsonBody, async (req, res) => { - await userAuthReady; - if (!userAuth || !rechargeService) { - return res.status(503).json({ message: '未启用计费系统' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const amountCents = Number(req.body?.amountCents); - const payScene = ['native', 'h5', 'jsapi'].includes(req.body?.payScene) - ? req.body.payScene - : 'native'; - const result = await rechargeService.createOrder({ - userId: me.id, - amountCents, - payScene, - clientIp: req.ip, - }); - if (!result.ok) return res.status(400).json({ message: result.message }); - return res.status(201).json({ order: result.order }); -}); - -app.get('/auth/billing/recharge-orders/:orderId', async (req, res) => { - await userAuthReady; - if (!userAuth || !rechargeService) { - return res.status(503).json({ message: '未启用计费系统' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const order = await rechargeService.getOrderForUser(me.id, req.params.orderId); - if (!order) return res.status(404).json({ message: '订单不存在' }); - let balanceCents = null; - if (order.status === 'paid') { - const user = await userAuth.getUserById(me.id); - balanceCents = user ? Number(user.balance_cents ?? 0) : null; - } - return res.json({ order, balanceCents }); +attachPortalBillingRoutes({ + app, + jsonBody, + userAuthReady, + getUserAuth: () => userAuth, + getRechargeService: () => rechargeService, + getSubscriptionService: () => subscriptionService, + getMindSpace: () => mindSpace, + userToken, }); const wechatNotifyBody = express.raw({ @@ -1945,184 +642,27 @@ const wechatMpBody = express.text({ type: ['text/xml', 'application/xml'], limit: '128kb', }); -app.post('/webhooks/wechat-pay/notify', wechatNotifyBody, async (req, res) => { - await userAuthReady; - const isV2 = wechatPayClient?.apiVersion === 'v2'; - if (!rechargeService || !wechatPayClient?.enabled) { - if (isV2) { - return res - .status(503) - .type('text/xml') - .send( - '', - ); - } - return res.status(503).json({ code: 'FAIL', message: '支付未启用' }); - } - try { - const bodyText = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body ?? ''); - await rechargeService.handleWechatNotify({ headers: req.headers, body: bodyText }); - if (isV2) { - return res.type('text/xml').send(WECHAT_NOTIFY_SUCCESS_V2); - } - return res.json({ code: 'SUCCESS', message: '成功' }); - } catch (err) { - console.error('WeChat notify failed:', err); - const message = err instanceof Error ? err.message : '处理失败'; - if (isV2) { - return res - .status(500) - .type('text/xml') - .send( - ``, - ); - } - return res.status(500).json({ code: 'FAIL', message }); - } +attachPortalWebhookRoutes({ + app, + userAuthReady, + wechatNotifyBody, + wechatMpBody, + wechatMpConfig: WECHAT_MP_CONFIG, + getRechargeService: () => rechargeService, + getWechatPayClient: () => wechatPayClient, + getWechatMpService: () => wechatMpService, + logger: console, }); -if (WECHAT_MP_CONFIG.enabled) { - app.get('/webhooks/wechat-mp/messages', async (req, res) => { - await userAuthReady; - if (!wechatMpService?.enabled) { - return res.status(503).send('wechat mp disabled'); - } - const result = wechatMpService.verifyUrlChallenge(req.query); - if (!result.ok) { - console.warn('WeChat MP verify failed:', { - encryptType: req.query.encrypt_type ?? null, - timestamp: req.query.timestamp ?? null, - nonce: req.query.nonce ?? null, - }); - return res.status(result.status ?? 403).send(result.body ?? 'invalid signature'); - } - console.log('WeChat MP verify ok:', { - encryptType: req.query.encrypt_type ?? null, - timestamp: req.query.timestamp ?? null, - nonce: req.query.nonce ?? null, - }); - return res.type('text/plain').send(String(result.body ?? '')); - }); - - app.post('/webhooks/wechat-mp/messages', wechatMpBody, async (req, res) => { - await userAuthReady; - if (!wechatMpService?.enabled) { - return res.status(503).send('wechat mp disabled'); - } - try { - const bodyText = String(req.body ?? ''); - const fromUser = bodyText.match(/<\/FromUserName>/)?.[1] ?? null; - const msgType = bodyText.match(/<\/MsgType>/)?.[1] ?? null; - const content = bodyText.match(/<\/Content>/)?.[1] ?? null; - console.log('WeChat MP message received:', { - at: new Date().toISOString(), - fromUser: fromUser ? `${fromUser.slice(0, 8)}...` : null, - msgType, - contentPreview: content ? `${String(content).slice(0, 24)}` : null, - }); - const result = await wechatMpService.handleInboundMessage(req.body, req.query); - if (result.task) void result.task; - if (result.contentType) res.type(result.contentType); - return res.status(result.status ?? 200).send(result.body ?? 'success'); - } catch (err) { - console.error('WeChat MP message failed:', err); - return res.status(500).send('internal error'); - } - }); -} - // ============ Wiki API ============ - -function wikiCookie(token, secure) { - const parts = [ - `${wikiAuth.COOKIE_NAME}=${encodeURIComponent(token)}`, - 'Path=/', - 'HttpOnly', - 'SameSite=Lax', - 'Max-Age=604800', - ]; - if (secure) parts.push('Secure'); - return parts.join('; '); -} - -function clearWikiCookie(secure) { - const parts = [`${wikiAuth.COOKIE_NAME}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0']; - if (secure) parts.push('Secure'); - return parts.join('; '); -} - const wikiApi = express.Router(); -wikiApi.use(jsonBody); - -wikiApi.post('/auth/register', (req, res) => { - const { username, password, displayName } = req.body || {}; - if (!username || !password) { - return res.status(400).json({ message: '用户名和密码不能为空' }); - } - const result = wikiAuth.register(username, password, displayName); - if (!result.ok) return res.status(409).json({ message: result.message }); - return res.json({ ok: true, user: result.user }); +attachPortalWikiRoutes({ + router: wikiApi, + jsonBody, + wikiAuth, + isSecureRequest, }); - -wikiApi.post('/auth/login', (req, res) => { - const { username, password } = req.body || {}; - if (!username || !password) { - return res.status(400).json({ message: '用户名和密码不能为空' }); - } - const result = wikiAuth.login(username, password); - if (!result.ok) return res.status(401).json({ message: result.message }); - const secure = isSecureRequest(req); - res.set('Set-Cookie', wikiCookie(result.token, secure)); - return res.json({ ok: true, user: result.user }); -}); - -wikiApi.post('/auth/logout', (req, res) => { - const cookies = parseCookies(req.get('cookie')); - wikiAuth.revoke(cookies[wikiAuth.COOKIE_NAME]); - res.set('Set-Cookie', clearWikiCookie(isSecureRequest(req))); - return res.json({ ok: true }); -}); - -wikiApi.get('/auth/me', (req, res) => { - const cookies = parseCookies(req.get('cookie')); - const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]); - if (!session) return res.json({ authenticated: false, user: null }); - const user = wikiAuth.getUser(session.username); - return res.json({ authenticated: true, user }); -}); - -function requireWikiAuth(req, res, next) { - const cookies = parseCookies(req.get('cookie')); - const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]); - if (!session) return res.status(401).json({ message: '未登录' }); - req.wikiUser = session; - next(); -} - -wikiApi.get('/pages', requireWikiAuth, (req, res) => { - const { q } = req.query; - if (q) return res.json(wikiAuth.searchPages(req.wikiUser.username, q)); - res.json(wikiAuth.listPages(req.wikiUser.username)); -}); - -wikiApi.get('/pages/:slug', requireWikiAuth, (req, res) => { - const page = wikiAuth.getPage(req.wikiUser.username, req.params.slug); - if (!page) return res.status(404).json({ message: '页面不存在' }); - res.json(page); -}); - -wikiApi.post('/pages/:slug', requireWikiAuth, (req, res) => { - const { title, content, tags } = req.body || {}; - const page = wikiAuth.savePage(req.wikiUser.username, req.params.slug, title, content, tags); - res.json(page); -}); - -wikiApi.delete('/pages/:slug', requireWikiAuth, (req, res) => { - wikiAuth.deletePage(req.wikiUser.username, req.params.slug); - res.json({ ok: true }); -}); - app.use('/wiki-api', wikiApi); // ============ TKMind API proxy ============ @@ -2131,69 +671,22 @@ const api = express.Router(); api.use(jsonUnlessMultipart); attachShenmeiOpinionFormRoutes(api, { rootDir: __dirname }); -api.use(async (req, res, next) => { - await userAuthReady; - if (req.path === '/status' || req.path === '/runtime/status') return next(); - if (req.path.startsWith('/internal/agent/')) return next(); - if (req.path === '/agent/mindspace_page_patch') return next(); - if (req.path === '/agent/mindspace_asset_delete') return next(); - if (req.path === '/agent/mindspace_asset_download') return next(); - if (req.path === '/agent/mindspace_image_generate') return next(); - if (req.path === '/internal/image-make/runtime-config') return next(); - if (req.path === '/config/blocked-words') return next(); - if (req.method === 'GET' && /^\/mindspace\/v1\/assets\/[^/]+\/download$/.test(req.path)) { - return next(); - } +api.use(createPortalApiAuthMiddleware({ + waitForUserAuthReady: () => userAuthReady, + getUserAuth: () => userAuth, + getTkmindProxy: () => tkmindProxy, + getLegacyAuth: () => legacyAuth, + getLegacySessionToken: legacySessionToken, + isPageDataPublicPath, + isLegacyPageDataApiPath, + accessPolicyMode: portalAccessPolicyMode, + accessEnforcementConfig: portalAccessEnforcementConfig, + accessShadowReporter: portalAccessShadowReporter, + logger: console, +})); - const plazaPublic = isPlazaPublicRead(req.path, req.method); - const pageDataPublic = isPageDataPublicPath(req.path, req.method); - // Let the retired namespace reach its explicit 410 handler below. Without - // this exception the outer auth middleware turns an old public HTML request - // into a misleading 401/403 before the legacy-endpoint block can run. - const legacyPageDataApi = isLegacyPageDataApiPath(req.path); - - if (userAuth && tkmindProxy) { - if (req.userSessionError) { - if (plazaPublic || pageDataPublic || legacyPageDataApi) return next(); - return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' }); - } - try { - if (req.userSession) { - const me = await userAuth.getMe(req.userToken); - if (me) req.currentUser = me; - } - if (plazaPublic || pageDataPublic || legacyPageDataApi) return next(); - if (!req.userSession) { - return res.status(401).json({ message: '未授权,请重新登录' }); - } - const me = await userAuth.getMe(req.userToken); - if (!me) return res.status(401).json({ message: '登录已过期' }); - req.currentUser = me; - return next(); - } catch (err) { - console.error('[Auth] API auth failed:', err instanceof Error ? err.message : err); - return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' }); - } - } - - if (legacyAuth?.verify(legacySessionToken(req))) return next(); - return res.status(401).json({ message: '未授权,请重新登录' }); -}); - -api.get('/internal/image-make/runtime-config', async (req, res) => { - if (!imageMakeAdminConfigService?.authorizeRuntimeRequest) { - return res.status(503).json({ message: 'image_make 配置服务未启用' }); - } - const authHeader = String(req.get('authorization') ?? ''); - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; - if (!imageMakeAdminConfigService.authorizeRuntimeRequest(token)) { - return res.status(401).json({ message: '未授权' }); - } - const runtime = await imageMakeAdminConfigService.getRuntimeConfig(); - if (!runtime.ok) { - return res.status(503).json({ message: runtime.message ?? 'image_make 运行时配置无效' }); - } - return res.json(runtime); +attachPortalImageMakeRuntimeConfigRoute(api, { + getImageMakeAdminConfigService: () => imageMakeAdminConfigService, }); attachAsrRoutes(api, { sendError, sendData }); @@ -2208,98 +701,19 @@ attachPageDataRoutes(api, { getPageDataPublicService: () => pageDataPublicService, }); -api.get('/config/blocked-words', async (_req, res) => { - await userAuthReady; - if (!wordFilterService) return res.json({ words: [] }); - const words = await wordFilterService.listAllForFrontend(); - res.json({ words }); +attachPortalBlockedWordsRoute(api, { + waitForUserAuthReady: () => userAuthReady, + getWordFilterService: () => wordFilterService, }); -api.get('/status', async (_req, res, next) => { - await userAuthReady; - if (userAuth && tkmindProxy) { - try { - const upstream = await tkmindProxy.apiFetch('/status', { method: 'GET' }); - const text = await upstream.text(); - return res.status(upstream.status).send(text); - } catch (err) { - return res.status(502).json({ message: err instanceof Error ? err.message : '代理失败' }); - } - } - return next(); -}); - -function runtimeEnvFlag(value, fallback = false) { - const raw = String(value ?? '').trim().toLowerCase(); - if (!raw) return fallback; - return ['1', 'true', 'yes', 'on'].includes(raw); -} - -function runtimeCsvList(value) { - return String(value ?? '') - .split(',') - .map((item) => item.trim()) - .filter(Boolean); -} - -function runtimeCodeRunPolicyStatus() { - return agentCodeRunPolicyService?.getRuntimeStatusSummary - ? agentCodeRunPolicyService.getRuntimeStatusSummary() - : Promise.resolve({ - source: 'env', - enabled: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED), - clientEnabled: runtimeEnvFlag(process.env.VITE_AGENT_CODE_RUNS_ENABLED), - userAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS), - taskTypeAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES), - requireValidation: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION), - pageDataDevAutodetect: runtimeEnvFlag(process.env.VITE_AGENT_PAGE_DATA_DEV_AUTODETECT), - generalAutodetect: runtimeEnvFlag(process.env.VITE_AGENT_CODE_RUNS_AUTODETECT), - envOverrideActive: String(process.env.MEMIND_CODE_RUN_POLICY_SOURCE ?? '').trim().toLowerCase() === 'env', - updatedAt: null, - updatedBy: null, - }); -} - -api.get('/runtime/status', async (_req, res) => { - await userAuthReady; - if (!tkmindProxy?.getRuntimeStatus) { - return res.status(503).json({ ok: false, message: 'runtime router unavailable' }); - } - try { - const status = await tkmindProxy.getRuntimeStatus(); - if (episodicMemoryService?.getStatus) { - status.memory = { - ...(status.memory ?? {}), - episodic: await episodicMemoryService.getStatus().catch((err) => ({ - configuredEnabled: false, - mode: 'off', - error: err instanceof Error ? err.message : String(err), - })), - }; - } - const toolQueue = agentRunGateway?.getQueueStatus - ? await agentRunGateway.getQueueStatus().catch((err) => ({ - error: err instanceof Error ? err.message : String(err), - })) - : null; - if (toolQueue) { - status.toolRuntime = { - ...(status.toolRuntime ?? {}), - codeRunPolicy: await runtimeCodeRunPolicyStatus(), - queue: toolQueue, - }; - } - return res.json({ - ok: true, - timestamp: new Date().toISOString(), - ...status, - }); - } catch (err) { - return res.status(502).json({ - ok: false, - message: err instanceof Error ? err.message : 'runtime status failed', - }); - } +attachPortalRuntimeRoutes(api, { + waitForUserAuthReady: () => userAuthReady, + getUserAuth: () => userAuth, + getTkmindProxy: () => tkmindProxy, + getEpisodicMemoryService: () => episodicMemoryService, + getAgentRunGateway: () => agentRunGateway, + getCodeRunPolicyService: () => agentCodeRunPolicyService, + env: process.env, }); async function ensureUserMemoryCapability(req, res) { @@ -2362,262 +776,27 @@ async function resolveUserMemoryItems(userId, { sessionId = null, limit = 200 } return conversationMemoryService.listMemories(userId, { limit }).catch(() => []); } -api.post('/user-memory/v1/remember-recent', async (req, res) => { - const memoryStatus = await memoryV2?.getStatus?.().catch(() => null); - if (!memoryStatus?.enabled) { - return res.status(503).json({ message: '长期记忆功能未启用' }); - } - if (!tkmindProxy) { - return res.status(503).json({ message: '会话代理尚未就绪' }); - } - const capabilityState = await ensureUserMemoryCapability(req, res); - if (!capabilityState) return; - - const sessionId = String(req.body?.sessionId ?? '').trim(); - if (!sessionId) { - return res.status(400).json({ message: '缺少 sessionId' }); - } - const owns = await ownsAgentSession(req.currentUser.id, sessionId); - if (!owns) { - return res.status(403).json({ message: '无权访问该会话' }); - } - - try { - const messages = await loadUserVisibleConversation(sessionId, req.currentUser.id); - const result = await memoryV2.write({ - userId: req.currentUser.id, - sessionId, - messages, - }); - const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId); - const memories = await resolveUserMemoryItems(req.currentUser.id, { - sessionId, - limit: 200, - }); - return res.json({ - ok: true, - analyzed: result.analyzed ?? 0, - memories: result.memories ?? 0, - totalMemories: memories.length, - syncedToSession, - }); - } catch (err) { - return res.status(500).json({ message: err instanceof Error ? err.message : '保存长期记忆失败' }); - } +attachPortalUserMemoryRoutes(api, { + getMemoryV2: () => memoryV2, + getTkmindProxy: () => tkmindProxy, + ensureUserMemoryCapability, + ownsAgentSession, + loadUserVisibleConversation, + syncUserMemoriesIntoSession, + resolveUserMemoryItems, }); -api.post('/user-memory/v1/sync', async (req, res) => { - const memoryStatus = await memoryV2?.getStatus?.().catch(() => null); - if (!memoryStatus?.enabled) { - return res.status(503).json({ message: '长期记忆功能未启用' }); - } - const capabilityState = await ensureUserMemoryCapability(req, res); - if (!capabilityState) return; - - const sessionId = String(req.body?.sessionId ?? '').trim(); - if (!sessionId) { - return res.status(400).json({ message: '缺少 sessionId' }); - } - const owns = await ownsAgentSession(req.currentUser.id, sessionId); - if (!owns) { - return res.status(403).json({ message: '无权访问该会话' }); - } - - try { - const result = await memoryV2.compact({ - userId: req.currentUser.id, - sessionId, - }); - const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId); - const memories = await resolveUserMemoryItems(req.currentUser.id, { - sessionId, - limit: 200, - }); - return res.json({ - ok: true, - analyzed: result.analyzed ?? 0, - memories: result.memories ?? 0, - totalMemories: memories.length, - syncedToSession, - }); - } catch (err) { - return res.status(500).json({ message: err instanceof Error ? err.message : '刷新长期记忆失败' }); - } +attachPortalMindSpaceSpaceRoutes(api, { + getMindSpace: () => mindSpace, + getScheduleService: () => scheduleService, + getMindSpaceCleanup: () => mindSpaceCleanup, + getMindSpaceAudit: () => mindSpaceAudit, + ensureMindSpaceEnabled, + sendData, + sendError, + handleMindSpaceError: mindSpaceError, }); -api.get('/user-memory/v1/items', async (req, res) => { - const capabilityState = await ensureUserMemoryCapability(req, res); - if (!capabilityState) return; - try { - const items = await memoryV2.listMemories?.({ - userId: req.currentUser.id, - status: String(req.query?.status ?? 'active'), - limit: req.query?.limit, - offset: req.query?.offset, - }) ?? []; - return res.json({ ok: true, items }); - } catch (err) { - return res.status(500).json({ message: err instanceof Error ? err.message : '读取长期记忆失败' }); - } -}); - -api.delete('/user-memory/v1/items/:memoryId', async (req, res) => { - const capabilityState = await ensureUserMemoryCapability(req, res); - if (!capabilityState) return; - try { - const result = await memoryV2.forgetMemory?.({ - userId: req.currentUser.id, - memoryId: req.params.memoryId, - }) ?? { ok: false, skipped: true, reason: 'unavailable' }; - if (result.skipped) return res.status(409).json(result); - return res.json(result); - } catch (err) { - return res.status(500).json({ message: err instanceof Error ? err.message : '删除长期记忆失败' }); - } -}); - -api.get('/mindspace/v1/space', async (req, res) => { - if (!mindSpace || !ensureMindSpaceEnabled(res, req)) return; - const space = await mindSpace.getSpace(req.currentUser.id); - if (!space) return sendError(res, req, 404, 'resource_not_found', '用户空间不存在'); - return sendData(res, req, space); -}); - -api.post('/mindspace/v1/schedule/reminders/:reminderId/ignore', async (req, res) => { - if (!ensureMindSpaceEnabled(res, req) || !scheduleService) { - return sendError(res, req, 503, 'feature_disabled', '日程服务未启用'); - } - try { - const reminder = await scheduleService.cancelReminder({ - userId: req.currentUser.id, - reminderId: req.params.reminderId, - }); - return sendData(res, req, reminder); - } catch (err) { - const message = err instanceof Error ? err.message : '忽略提醒失败'; - return sendError(res, req, 400, 'invalid_schedule_input', message); - } -}); - -api.post('/mindspace/v1/schedule/reminders/bulk-delete', async (req, res) => { - if (!ensureMindSpaceEnabled(res, req) || !scheduleService) { - return sendError(res, req, 503, 'feature_disabled', '日程服务未启用'); - } - try { - const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String) : []; - const deleted = await scheduleService.deleteReminders({ - userId: req.currentUser.id, - reminderIds: ids, - }); - return sendData(res, req, { deleted }); - } catch (err) { - const message = err instanceof Error ? err.message : '删除提醒失败'; - return sendError(res, req, 400, 'invalid_schedule_input', message); - } -}); - -api.get('/mindspace/v1/space/quota', async (req, res) => { - if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' }); - const quota = await mindSpace.getQuota(req.currentUser.id); - if (!quota) return res.status(404).json({ message: '用户空间不存在' }); - return res.json({ data: quota }); -}); - -api.get('/mindspace/v1/space/categories', async (req, res) => { - if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' }); - const categories = await mindSpace.listCategories(req.currentUser.id); - if (!categories) return res.status(404).json({ message: '用户空间不存在' }); - return res.json({ data: categories }); -}); - -api.get('/mindspace/v1/space/cleanup', async (req, res) => { - if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return; - try { - const username = req.currentUser.username ?? req.currentUser.slug; - const items = await mindSpaceCleanup.listCandidates(req.currentUser.id, username); - const totalBytes = items.reduce((sum, item) => sum + item.sizeBytes, 0); - return sendData(res, req, { items, totalBytes }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/space/cleanup', async (req, res) => { - if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return; - try { - const username = req.currentUser.username ?? req.currentUser.slug; - const itemIds = Array.isArray(req.body?.item_ids) ? req.body.item_ids : []; - const result = await mindSpaceCleanup.runCleanup(req.currentUser.id, username, itemIds); - const quota = await mindSpace.getQuota(req.currentUser.id); - await mindSpaceAudit?.write({ - userId: req.currentUser.id, - action: 'space.cleanup', - objectType: 'space', - objectId: req.currentUser.id, - ip: req.ip, - detail: { removedCount: result.removedCount, freedBytes: result.freedBytes }, - }); - return sendData(res, req, { ...result, quota }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -function isPlazaPublicRead(path, method) { - if (!path.startsWith('/plaza/v1/')) return false; - if (method === 'POST' && path === '/plaza/v1/events') return true; - if (method !== 'GET') return false; - return ( - path === '/plaza/v1/feed' || - path === '/plaza/v1/categories' || - path === '/plaza/v1/seo/sitemap' || - /^\/plaza\/v1\/posts\/[^/]+$/.test(path) || - /^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path) || - /^\/plaza\/v1\/users\/[^/]+$/.test(path) || - /^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path) - ); -} - -const PLAZA_SID_COOKIE = 'plaza_sid'; - -function resolvePlazaSessionId(req, res) { - const cookies = parseCookies(req.get('cookie')); - let sessionId = cookies[PLAZA_SID_COOKIE]; - if (!sessionId) { - sessionId = crypto.randomUUID(); - res.append( - 'Set-Cookie', - `${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`, - ); - } - return sessionId; -} - -function recordPlazaEventsAsync(req, res, events) { - if (!plazaEvents || !Array.isArray(events) || events.length === 0) return; - const sessionId = resolvePlazaSessionId(req, res); - void plazaEvents - .recordEvents({ - userId: req.currentUser?.id ?? null, - sessionId, - events, - }) - .catch(() => {}); -} - -function reactionEventType(type) { - if (type === 'like' || type === 'collect' || type === 'share') return type; - return null; -} - -function ensurePlazaInteractions(res, req) { - if (!plazaInteractions) { - sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); - return false; - } - return true; -} - function resolvePlazaPostUrlForRequest(postId, req) { const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(':')[0]; const base = resolvePlazaPublicBase({ @@ -2635,14 +814,6 @@ function plazaRouteError(res, req, error) { return sendError(res, req, status, code, message, error?.details); } -function ensurePlazaEnabled(res, req) { - if (!plazaPosts) { - sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); - return false; - } - return true; -} - function plazaClientIp(req) { const forwarded = req.headers['x-forwarded-for']; if (typeof forwarded === 'string' && forwarded.length > 0) { @@ -2758,636 +929,46 @@ function requireInternalAgentSecret(req, res) { return false; } -api.post('/mindspace/v1/agent/jobs', async (req, res) => { - if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; - try { - const job = await mindSpaceAgentJobs.createJob(req.currentUser.id, { - jobType: req.body?.job_type, - instruction: req.body?.instruction, - allowedAssetIds: req.body?.allowed_asset_ids, - outputCategoryId: req.body?.output_category_id, - outputType: req.body?.output_type, - idempotencyKey: req.body?.idempotency_key, - locale: req.body?.locale, - timezone: req.body?.timezone, - capabilities: req.body?.capabilities, - }); - await mindSpaceAudit?.write({ - userId: req.currentUser.id, - action: 'agent_access', - objectType: 'agent_job', - objectId: job.id, - ip: req.ip, - detail: { - jobType: job.jobType, - assetIds: job.assets.map((asset) => asset.assetId), - }, - }); - return sendData(res, req, job, 201); - } catch (error) { - return mindSpaceError(res, req, error); - } +attachPortalAgentJobRoutes(api, { + getMindSpaceAgentJobs: () => mindSpaceAgentJobs, + getMindSpaceAgentRunner: () => mindSpaceAgentRunner, + getMindSpaceAudit: () => mindSpaceAudit, + ensureMindSpaceEnabled, + requireInternalAgentSecret, + bearerToken, + sendData, + handleMindSpaceError: mindSpaceError, + getSsePollMs: () => mindSpaceServerRuntime.agentWorker.ssePollMs, + logger: console, }); -api.get('/mindspace/v1/agent/jobs/:jobId', async (req, res) => { - if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; - try { - return sendData(res, req, await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId)); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -// Server-Sent Events stream of a job's progress, so long-running agent tasks can -// be dispatched async (enqueue → 202 → subscribe here) instead of holding a -// synchronous streaming connection. Polls the job (ownership enforced by getJob) -// and pushes on change; closes on terminal status or client disconnect. -api.get('/mindspace/v1/agent/jobs/:jobId/stream', async (req, res) => { - if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; - const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'timed_out']); - let job; - try { - job = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId); - } catch (error) { - return mindSpaceError(res, req, error); - } - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - const send = (event, payload) => { - res.write(`event: ${event}\n`); - res.write(`data: ${JSON.stringify(payload)}\n\n`); - }; - let lastSignature = ''; - const emitIfChanged = (current) => { - const signature = `${current.status}:${JSON.stringify(current.progress ?? {})}`; - if (signature !== lastSignature) { - lastSignature = signature; - send('progress', current); - } - return signature; - }; - emitIfChanged(job); - if (TERMINAL.has(job.status)) { - send('done', job); - return res.end(); - } - let closed = false; - const cleanup = () => { - if (closed) return; - closed = true; - clearInterval(pollTimer); - clearInterval(keepAliveTimer); - }; - const pollTimer = setInterval(async () => { - if (closed) return; - try { - const current = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId); - emitIfChanged(current); - if (TERMINAL.has(current.status)) { - send('done', current); - cleanup(); - res.end(); - } - } catch { - // Job vanished or transient read error: end the stream rather than leak it. - cleanup(); - res.end(); - } - }, mindSpaceServerRuntime.agentWorker.ssePollMs); - // Comment line keeps proxies from closing an idle connection. - const keepAliveTimer = setInterval(() => { - if (!closed) res.write(': keep-alive\n\n'); - }, 15_000); - pollTimer.unref?.(); - keepAliveTimer.unref?.(); - req.on('close', cleanup); -}); - -api.get('/mindspace/v1/agent/jobs', async (req, res) => { - if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; - try { - const result = await mindSpaceAgentJobs.listJobs(req.currentUser.id, { - limit: Number(req.query.limit ?? 10), - offset: Number(req.query.offset ?? 0), - }); - return res.json({ - data: result.items, - page: { - total: result.total, - offset: result.offset, - limit: result.limit, - has_more: result.hasMore, - }, - request_id: req.requestId, - }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/agent/jobs/:jobId/cancel', async (req, res) => { - if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; - try { - return sendData( - res, - req, - await mindSpaceAgentJobs.cancelJob(req.currentUser.id, req.params.jobId), - ); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/agent/jobs/:jobId/retry', async (req, res) => { - if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; - try { - return sendData( - res, - req, - await mindSpaceAgentJobs.retryJob(req.currentUser.id, req.params.jobId), - ); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/agent/jobs/:jobId/run', async (req, res) => { - if ( - !mindSpaceAgentJobs || - !mindSpaceAgentRunner || - !ensureMindSpaceEnabled(res, req, { agent: true }) - ) { - return; - } - try { - const job = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId); - if (job.status !== 'queued') { - return sendData(res, req, job); - } - void mindSpaceAgentRunner.runJob(req.params.jobId).catch((error) => { - console.error('MindSpace agent job run failed:', error); - }); - return sendData(res, req, { started: true, jobId: req.params.jobId }, 202); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/internal/agent/jobs/:jobId/claim', async (req, res) => { - if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; - if (!requireInternalAgentSecret(req, res)) return; - try { - return sendData(res, req, await mindSpaceAgentJobs.claimJob(req.params.jobId)); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.get('/internal/agent/jobs/:jobId/assets/:assetId', async (req, res) => { - if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; - try { - const asset = await mindSpaceAgentJobs.getAssetForJob( - req.params.jobId, - bearerToken(req), - req.params.assetId, - ); - res.set('Content-Type', asset.mimeType); - res.set('Content-Disposition', `inline; filename="${encodeURIComponent(asset.displayName)}"`); - res.set('Cache-Control', 'private, no-store'); - res.setHeader('X-Request-Id', req.requestId); - return res.send(await fs.promises.readFile(asset.path)); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/internal/agent/jobs/:jobId/heartbeat', async (req, res) => { - if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; - try { - return sendData( - res, - req, - await mindSpaceAgentJobs.heartbeat(req.params.jobId, bearerToken(req), { - stage: req.body?.stage, - message: req.body?.message, - }), - ); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/internal/agent/jobs/:jobId/complete', async (req, res) => { - if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; - try { - const job = await mindSpaceAgentJobs.completeJob(req.params.jobId, bearerToken(req), { - status: req.body?.status, - errorCode: req.body?.error_code, - errorMessage: req.body?.error_message, - outputType: req.body?.output_type, - title: req.body?.title, - summary: req.body?.summary, - content: req.body?.content, - contentFormat: req.body?.content_format, - pageType: req.body?.page_type, - templateId: req.body?.template_id, - sourceAssetIds: req.body?.source_asset_ids, - }); - return sendData(res, req, job); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/conversation-packages/:sessionId', createGetConversationPackageHandler({ - getRegistry: () => mindSpaceConversationPackageRegistry, +attachPortalMindSpaceAssetDeliveryRoutes(api, { + rawUploadBody, + getConversationPackageRegistry: () => mindSpaceConversationPackageRegistry, getUserAuth: () => userAuth, getSessionAccess: () => sessionAccess, - ensureMindSpaceEnabled, - sendData, - mindSpaceError, beforeReadManifest: ({ req, sessionId }) => mindSpaceServiceFacade?.prepareConversationPackageRead({ user: req.currentUser, sessionId, }), -})); - -api.get( - '/mindspace/v1/conversation-packages/:sessionId/manifest.json', - createDownloadConversationPackageManifestHandler({ - getRegistry: () => mindSpaceConversationPackageRegistry, - getUserAuth: () => userAuth, - getSessionAccess: () => sessionAccess, - ensureMindSpaceEnabled, - mindSpaceError, - beforeReadManifest: ({ req, sessionId }) => - mindSpaceServiceFacade?.prepareConversationPackageRead({ - user: req.currentUser, - sessionId, - }), - }), -); - -api.get( - '/mindspace/v1/conversation-packages/:sessionId/artifacts/:artifactId/download', - createDownloadConversationPackageArtifactHandler({ - getRegistry: () => mindSpaceConversationPackageRegistry, - getUserAuth: () => userAuth, - getSessionAccess: () => sessionAccess, - ensureMindSpaceEnabled, - mindSpaceError, - }), -); - -api.post('/mindspace/v1/uploads', async (req, res) => { - if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return; - try { - const upload = await mindSpaceAssets.createUpload(req.currentUser.id, { - categoryId: req.body?.category_id, - filename: req.body?.filename, - sizeBytes: req.body?.size_bytes, - declaredMimeType: req.body?.declared_mime_type, - sourceSessionId: req.body?.session_id, - sourceMessageId: req.body?.message_id, - }); - return sendData(res, req, upload, 201); - } catch (error) { - return mindSpaceError(res, req, error); - } + getMindSpaceAssets: () => mindSpaceAssets, + ensureMindSpaceEnabled, + sendData, + handleMindSpaceError: mindSpaceError, }); -api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBody, async (req, res) => { - if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const result = await mindSpaceAssets.writeUploadContent( - req.currentUser.id, - req.params.uploadId, - req.body, - ); - return res.json({ data: result }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/uploads/:uploadId/complete', async (req, res) => { - if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const asset = await mindSpaceAssets.completeUpload( - req.currentUser.id, - req.params.uploadId, - ); - return res.status(201).json({ data: asset }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/conversation-packages/:sessionId/claim-uploads', async (req, res) => { - if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return; - try { - const result = await mindSpaceAssets.claimUploadArtifactsForConversation(req.currentUser.id, { - sessionId: req.params.sessionId, - messageId: req.body?.message_id, - }); - return sendData(res, req, result); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.delete('/mindspace/v1/uploads/:uploadId', async (req, res) => { - if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const result = await mindSpaceAssets.cancelUpload( - req.currentUser.id, - req.params.uploadId, - ); - return res.json({ data: result }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/assets', async (req, res) => { - if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const assets = await mindSpaceAssets.listAssets(req.currentUser.id, { - categoryId: typeof req.query.category_id === 'string' ? req.query.category_id : undefined, - categoryCode: - typeof req.query.category_code === 'string' ? req.query.category_code : undefined, - }); - return res.json({ data: assets, page: { next_cursor: null, has_more: false } }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/authorize-image', async (req, res) => { - if (!mindSpacePublications) { - return res.status(503).json({ error: 'Service unavailable' }); - } - const assetId = String(req.query.asset_id ?? ''); - if (!assetId) { - return res.status(400).json({ error: 'Missing asset_id parameter' }); - } - try { - const [refs] = await pool.query( - `SELECT pr.access_mode, pr.expires_at - FROM h5_publication_asset_refs refs - JOIN h5_publish_records pr ON refs.publication_id = pr.id - WHERE refs.asset_id = ? AND pr.status = 'online' - ORDER BY CASE - WHEN pr.access_mode = 'public' THEN 0 - WHEN pr.access_mode = 'time_limited' THEN 1 - ELSE 2 - END, - pr.expires_at DESC - LIMIT 1`, - [assetId], - ); - - if (!refs[0]) { - return res.status(403).json({ error: 'Forbidden' }); - } - - const publication = refs[0]; - const now = Date.now(); - - if (publication.access_mode === 'public') { - res.set('Cache-Control', 'public, max-age=31536000, immutable'); - return res.status(200).json({ ok: true }); - } - - if ( - publication.access_mode === 'time_limited' && - publication.expires_at && - Number(publication.expires_at) > now - ) { - res.set('Cache-Control', 'public, max-age=60'); - return res.status(200).json({ ok: true }); - } - - return res.status(403).json({ error: 'Forbidden' }); - } catch (error) { - console.error('[authorize-image]', error instanceof Error ? error.message : error); - return res.status(500).json({ error: 'Internal server error' }); - } -}); - -api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => { - if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; - try { - const assetId = req.params.assetId; - const currentUserId = req.currentUser?.id ?? req.userSession?.userId ?? null; - let allowedByPublication = false; - let publicationAccessMode = null; - if (!currentUserId) { - const referrer = String(req.get('referer') ?? req.get('referrer') ?? ''); - const isPublicPageReferrer = (() => { - if (!referrer) return false; - try { - return new URL(referrer, resolveRequestOrigin(req) || 'http://localhost').pathname.includes('/public/'); - } catch { - return referrer.includes('/public/'); - } - })(); - const [refs] = await authPool.query( - `SELECT pr.access_mode, pr.expires_at - FROM h5_publication_asset_refs refs - JOIN h5_publish_records pr ON refs.publication_id = pr.id - WHERE refs.asset_id = ? AND pr.status = 'online' - ORDER BY CASE - WHEN pr.access_mode = 'public' THEN 0 - WHEN pr.access_mode = 'time_limited' THEN 1 - ELSE 2 - END, - pr.expires_at DESC - LIMIT 1`, - [assetId], - ); - const publication = refs[0] ?? null; - if (publication?.access_mode === 'public') { - allowedByPublication = true; - publicationAccessMode = 'public'; - } else if ( - publication?.access_mode === 'time_limited' && - publication.expires_at && - Number(publication.expires_at) > Date.now() - ) { - allowedByPublication = true; - publicationAccessMode = 'time_limited'; - } - if (!allowedByPublication && verifyPublicAssetToken(assetId, req.query.public_token, INTERNAL_AGENT_SECRET)) { - allowedByPublication = true; - publicationAccessMode = 'signed-public-token'; - } - if (!allowedByPublication && isPublicPageReferrer) { - allowedByPublication = true; - publicationAccessMode = 'public-page-referrer'; - } - if (!allowedByPublication) { - return res.status(401).json({ message: '未授权,请重新登录' }); - } - } - const { asset, path: assetPath } = currentUserId - ? await mindSpaceAssets.readAsset(currentUserId, assetId) - : await mindSpaceAssets.readPublicAsset(assetId); - await mindSpaceAudit?.write({ - userId: currentUserId, - action: 'asset.download', - objectType: 'asset', - objectId: assetId, - ip: req.ip, - riskLevel: asset.riskLevel, - detail: allowedByPublication ? { accessMode: publicationAccessMode } : undefined, - }); - res.type(asset.mimeType); - const inline = - req.query.inline === '1' || - req.query.disposition === 'inline' || - req.get('sec-fetch-dest') === 'iframe'; - if (inline && asset.mimeType.startsWith('image/') && wantsInlineImageViewer(req)) { - const downloadUrl = `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download?inline=1`; - const html = renderImageAssetViewerHtml({ asset, downloadUrl }); - res.set('Content-Type', 'text/html; charset=utf-8'); - res.set('Cache-Control', 'private, no-store'); - res.setHeader('X-Request-Id', req.requestId); - return res.send(html); - } - res.set( - 'Content-Disposition', - inline - ? `inline; filename*=UTF-8''${encodeURIComponent(asset.filename)}` - : `attachment; filename*=UTF-8''${encodeURIComponent(asset.filename)}`, - ); - res.setHeader('X-Request-Id', req.requestId); - return res.sendFile(assetPath); - } catch (error) { - await mindSpaceAudit?.write({ - userId: req.currentUser?.id ?? null, - action: 'asset.download', - objectType: 'asset', - objectId: req.params.assetId, - ip: req.ip, - result: 'denied', - riskLevel: error?.code === 'security_risk_blocked' ? 'high' : null, - }); - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/assets/:assetId/thumbnail', async (req, res) => { - if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; - try { - const svg = await mindSpaceAssets.renderAssetThumbnail(req.currentUser.id, req.params.assetId); - res.set('Content-Type', 'image/svg+xml; charset=utf-8'); - res.set('Cache-Control', 'private, max-age=300'); - res.setHeader('X-Request-Id', req.requestId); - return res.send(svg); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/assets/:assetId/preview', async (req, res) => { - if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; - try { - const html = await mindSpaceAssets.renderAssetPreview(req.currentUser.id, req.params.assetId); - res.set('Content-Type', 'text/html; charset=utf-8'); - res.set('Cache-Control', 'private, no-store'); - res.setHeader('X-Request-Id', req.requestId); - return res.send(html); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/from-asset', async (req, res) => { - if (!mindSpacePages || !mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const assetId = req.body?.asset_id; - const existingPage = await mindSpacePages.findPageBySourceAsset(req.currentUser.id, assetId); - if (existingPage) { - return sendData(res, req, { kind: 'page', categoryCode: existingPage.categoryCode ?? 'draft', page: existingPage }); - } - const { asset, path: assetPath } = await mindSpaceAssets.readAsset( - req.currentUser.id, - assetId, - ); - if (asset.mimeType === 'text/html') { - const relativePath = normalizeWorkspaceRelativePath( - String(asset.originalFilename ?? asset.original_filename ?? '').includes('/') - ? asset.originalFilename ?? asset.original_filename - : `public/${asset.originalFilename ?? asset.original_filename ?? ''}`, - ); - const existingByPath = relativePath - ? await mindSpacePages.findPageByRelativePath(req.currentUser.id, relativePath).catch(() => null) - : null; - if (existingByPath) { - return sendData(res, req, { - kind: 'page', - categoryCode: existingByPath.categoryCode ?? 'draft', - page: existingByPath, - }); - } - } - const content = await fs.promises.readFile(assetPath, 'utf8'); - const contentFormat = asset.mimeType === 'text/html' ? 'html' : 'markdown'; - const htmlRelativePath = - contentFormat === 'html' - ? normalizeWorkspaceRelativePath( - String(asset.originalFilename ?? asset.original_filename ?? '').includes('/') - ? asset.originalFilename ?? asset.original_filename - : `public/${asset.originalFilename ?? asset.original_filename ?? ''}`, - ) - : null; - const page = await mindSpacePages.createFromChat( - req.currentUser.id, - { - title: req.body?.title || asset.displayName, - summary: req.body?.summary, - content, - contentFormat, - templateId: req.body?.template_id, - categoryCode: 'draft', - }, - { - assetId: asset.id, - snapshot: { - source_asset_id: asset.id, - source_category: asset.categoryCode, - content_mode: contentFormat, - ...(htmlRelativePath ? { relative_path: htmlRelativePath } : {}), - }, - }, - ); - return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.delete('/mindspace/v1/assets/:assetId', async (req, res) => { - if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; - try { - const result = await mindSpaceAssets.deleteAsset(req.currentUser.id, req.params.assetId); - await mindSpaceAudit?.write({ - userId: req.currentUser.id, - action: 'asset.delete', - objectType: 'asset', - objectId: req.params.assetId, - ip: req.ip, - }); - return sendData(res, req, result); - } catch (error) { - return mindSpaceError(res, req, error); - } +attachPortalMindSpaceAssetRoutes(api, { + getMindSpacePublications: () => mindSpacePublications, + getDatabasePool: () => authPool, + getMindSpaceAssets: () => mindSpaceAssets, + getMindSpacePages: () => mindSpacePages, + getMindSpaceAudit: () => mindSpaceAudit, + getInternalAgentSecret: () => INTERNAL_AGENT_SECRET, + ensureMindSpaceEnabled, + resolveRequestOrigin, + sendData, + handleMindSpaceError: mindSpaceError, }); function messageText(message) { @@ -3478,8 +1059,6 @@ async function resolveExistingSavedPage(userId, { sessionId, messageId, relative return mindSpacePages.findPageByRelativePath(userId, normalizedPath).catch(() => null); } -const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']); - // REGRESSION GUARD: mindspace-page-sync-thumbnail — remote 也经 pageSyncService RPC 同步 public HTML async function listSessionPublicHtmlRelativePaths(userId, sessionId, { sinceMs = null } = {}) { if (!authPool || !userId || !sessionId) return []; @@ -3515,7 +1094,7 @@ async function syncUserGeneratedPages(userId, { sessionId = null, sinceMs = null // block an unrelated delivery. const recentWorkspaceRelativePaths = sessionId && !discoveredRelativePaths?.length ? listRecentlyModifiedPublicHtmlRelativePaths( - resolveMindSpaceUserPublishDir(__dirname, { id: userId }), + resolveMindSpaceUserPublishDir(H5_ROOT, { id: userId }), { sinceMs }, ) : []; @@ -3687,7 +1266,7 @@ async function registerPublicHtmlArtifactsForConversation({ }) { return registerPublicHtmlArtifactsForConversationPackage({ conversationPackageRegistry: mindSpaceConversationPackageRegistry, - h5Root: __dirname, + h5Root: H5_ROOT, env: process.env, user, publishDir, @@ -3698,1813 +1277,127 @@ async function registerPublicHtmlArtifactsForConversation({ }); } -api.get('/mindspace/v1/pages/chat-save-preview', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.query); - if (!bundle.resolvedHtml) { - throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); - } - const baseHref = buildWorkspaceBaseHref(req.currentUser.id, bundle.resolvedHtml.relativePath); - const html = injectHtmlBaseHref(bundle.resolvedHtml.content, baseHref); - res.set('Content-Type', 'text/html; charset=utf-8'); - res.set('Cache-Control', 'private, no-store'); - res.setHeader('X-Request-Id', req.requestId); - return res.send(html); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.query); - if (!bundle.resolvedHtml) { - throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); - } - const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); - const thumbRel = workspaceThumbnailRelativePath(bundle.resolvedHtml.relativePath); - const title = - bundle.previewTitle || - bundle.resolvedHtml.suggestedTitle || - bundle.analysis.suggestedTitle; - const subtitle = - bundle.previewSummary || - bundle.resolvedHtml.suggestedSummary || - bundle.analysis.suggestedSummary; - await ensureWorkspaceHtmlThumbnail( - publishDir, - bundle.resolvedHtml.relativePath, - bundle.resolvedHtml.content, - { title, subtitle, force: true }, - ).catch(() => {}); - const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env); - const resolveAssetDataUri = createAssetDataUriResolver( - authPool, - storageRoot, - req.currentUser.id, - ); - const svg = await generateHtmlThumbnail(publishDir, thumbRel, bundle.resolvedHtml.content, { - title, - subtitle, - contentBaseDir: path.dirname(bundle.resolvedHtml.absolute), - resolveAssetDataUri, - force: true, - }); - res.set('Content-Type', 'image/svg+xml; charset=utf-8'); - res.set('Cache-Control', 'private, no-store'); - res.setHeader('X-Request-Id', req.requestId); - return res.send(svg); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const sessionId = req.body?.session_id; - const messageId = req.body?.message_id; - const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body); - const { - source, - analysis, - resolvedHtml, - previewTitle, - previewSummary, - previewFrameUrl, - thumbnailUrl, - localPreviewUrl, - localThumbnailUrl, - } = bundle; - let thumbnailReady = false; - if (resolvedHtml?.content && resolvedHtml.relativePath) { - const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); - try { - await ensureWorkspaceHtmlThumbnail(publishDir, resolvedHtml.relativePath, resolvedHtml.content, { - title: previewTitle || resolvedHtml.suggestedTitle, - subtitle: previewSummary || resolvedHtml.suggestedSummary, - force: Boolean(previewTitle || previewSummary), - }); - thumbnailReady = true; - } catch { - thumbnailReady = false; - } - } - const existingPage = await resolveExistingSavedPage(req.currentUser.id, { - sessionId, - messageId, - relativePath: resolvedHtml?.relativePath ?? analysis.relativePath, - }); - return sendData(res, req, { - contentMode: analysis.contentMode, - links: analysis.links, - selectedLinkIndex: analysis.selectedLink - ? analysis.links.findIndex((link) => link.publicUrl === analysis.selectedLink.publicUrl) - : -1, - suggestedTitle: resolvedHtml?.suggestedTitle ?? analysis.suggestedTitle, - suggestedSummary: resolvedHtml?.suggestedSummary ?? analysis.suggestedSummary, - previewUrl: analysis.previewUrl, - previewFrameUrl, - localPreviewUrl, - localThumbnailUrl, - thumbnailUrl: resolvedHtml ? thumbnailUrl : null, - thumbnailReady, - relativePath: resolvedHtml?.relativePath ?? analysis.relativePath, - filename: resolvedHtml?.filename ?? analysis.filename, - hasHtmlContent: Boolean(resolvedHtml), - privacyScan: scanContent(resolvedHtml?.content ?? source.content), - existingPage: existingPage - ? { id: existingPage.id, title: existingPage.title, categoryCode: existingPage.categoryCode } - : null, - }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body); - console.log('[quick-share] bundle resolved, hasHtml:', Boolean(bundle.resolvedHtml)); - if (!bundle.resolvedHtml) { - throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); - } - - const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env); - const { html: localizedHtml } = await inlinePrivateAssetsInHtml( - authPool, - storageRoot, - req.currentUser.id, - bundle.resolvedHtml.content, - ); - - const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); - const sharedDir = path.join(publishDir, PUBLIC_ZONE_DIR, 'shared'); - await fsPromises.mkdir(sharedDir, { recursive: true }); - - const basename = bundle.resolvedHtml.filename.replace(/\.html$/i, ''); - const filename = `${basename}-${crypto.randomUUID().slice(0, 8)}.html`; - const sharedRelativePath = `${PUBLIC_ZONE_DIR}/shared/${filename}`; - const sharedHtml = rewriteWorkspacePublicAssetReferences(localizedHtml, sharedRelativePath); - const destPath = path.join(sharedDir, filename); - await fsPromises.writeFile(destPath, sharedHtml, 'utf8'); - - const publishKey = req.currentUser.id; - const publicUrl = buildMindSpacePublicUrlForUser({ - h5Root: __dirname, - env: process.env, - user: publishKey, - relativePath: sharedRelativePath, - }); - - return res.status(201).json({ data: { publicUrl, filename } }); - } catch (error) { - console.error('[quick-share] error:', error); - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/quick-plaza-from-chat', async (req, res) => { - if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { - return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用'); - } - const startedAt = Date.now(); - try { - const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body); - const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); - const result = await quickPlazaFromChat({ - user: req.currentUser, - h5Root: __dirname, - bundle, - body: req.body ?? {}, - mindSpacePages, - mindSpacePublications, - plazaPosts, - publishDir, - }); - console.info('[quick-plaza] ok', { - ms: Date.now() - startedAt, - pageId: result.pageId, - publicationId: result.publicationId, - postId: result.post?.id, - }); - return sendData(res, req, result, 201); - } catch (error) { - console.error('[quick-plaza] error:', { ms: Date.now() - startedAt, error }); - if (error?.code && mapPlazaError(error) !== 500) { - return plazaRouteError(res, req, error); - } - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/pages/quick-plaza-from-public-html/status', async (req, res) => { - if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { - return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用'); - } - try { - const relativePath = String(req.query?.relative_path ?? req.query?.relativePath ?? '').trim(); - const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); - const result = await getQuickPlazaFromPublicHtmlStatus({ - user: req.currentUser, - relativePath, - mindSpacePages, - mindSpacePublications, - plazaPosts, - }); - const postId = result.post?.id; - return sendData(res, req, { - ...result, - plaza_url: postId ? resolvePlazaPostUrlForRequest(postId, req) : null, - }); - } catch (error) { - if (error?.code && mapPlazaError(error) !== 500) { - return plazaRouteError(res, req, error); - } - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/quick-plaza-from-public-html', async (req, res) => { - if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { - return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用'); - } - const startedAt = Date.now(); - try { - const relativePath = String(req.body?.relative_path ?? req.body?.relativePath ?? '').trim(); - const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); - const result = await quickPlazaFromPublicHtml({ - user: req.currentUser, - relativePath, - mindSpacePages, - mindSpacePublications, - plazaPosts, - publishDir, - }); - const plazaUrl = resolvePlazaPostUrlForRequest(result.post.id, req); - console.info('[quick-plaza-public-html] ok', { - ms: Date.now() - startedAt, - pageId: result.pageId, - publicationId: result.publicationId, - postId: result.post?.id, - relativePath, - }); - return sendData(res, req, { ...result, plaza_url: plazaUrl }, 201); - } catch (error) { - console.error('[quick-plaza-public-html] error:', { ms: Date.now() - startedAt, error }); - if (error?.code === 'ALREADY_PUBLISHED') { - const postId = error?.details?.post_id ?? error?.details?.postId; - if (postId) { - error.details = { - ...(error.details ?? {}), - plaza_url: resolvePlazaPostUrlForRequest(postId, req), - }; - } - } - if (error?.code && mapPlazaError(error) !== 500) { - return plazaRouteError(res, req, error); - } - return mindSpaceError(res, req, error); - } -}); - -async function handleChatSaveDocx(req, res) { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body); - const title = - bundle.previewTitle || - bundle.resolvedHtml?.suggestedTitle || - bundle.analysis?.suggestedTitle || - 'AI 创作文档'; - const content = bundle.resolvedHtml?.content ?? bundle.source.content; - const contentFormat = bundle.resolvedHtml?.content ? 'html' : 'markdown'; - const buffer = generateDocxBuffer({ title, content, contentFormat }); - const filenameBase = String(title) - .replace(/[\\/:*?"<>|]+/g, '-') - .replace(/\s+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 60) || 'mindspace-document'; - const filename = `${filenameBase}.docx`; - await registerChatDocxArtifactForConversation({ - registry: mindSpaceConversationPackageRegistry, - user: req.currentUser, - bundle, - requestBody: req.body, - filename, - buffer, - }).catch((error) => { - console.warn('[MindSpace] failed to record chat docx artifact:', error?.message ?? error); - }); - res.set('Content-Type', DOCX_MIME_TYPE); - res.set( - 'Content-Disposition', - `attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`, - ); - res.set('Cache-Control', 'no-store'); - res.setHeader('X-Request-Id', req.requestId); - return res.send(buffer); - } catch (error) { - return mindSpaceError(res, req, error); - } -} - -api.post('/mindspace/v1/pages/chat-save-docx', handleChatSaveDocx); -api.post('/v1/pages/chat-save-docx', handleChatSaveDocx); - -api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { - if (!mindSpacePages || !mindSpaceAssets) { - return res.status(503).json({ message: 'MindSpace 未启用' }); - } - try { - const categoryCode = String(req.body?.category_code ?? 'draft'); - if (!SAVE_TARGET_CATEGORIES.has(categoryCode)) { - throw Object.assign(new Error('无效的保存目标'), { code: 'invalid_category_code' }); - } - const source = await resolveOwnedAssistantMessage( - req.currentUser, - req.body?.session_id, - req.body?.message_id, - ); - const analysis = analyzeChatMessageForSave({ - content: source.content, - userId: req.currentUser.id, - username: req.currentUser.username, - h5Root: __dirname, - selectedLinkIndex: Number(req.body?.selected_link_index ?? 0), - }); - const snapshot = { - session_name: source.session.name, - message_created: source.message.created, - role: source.message.role, - content_mode: analysis.contentMode, - public_url: analysis.previewUrl, - relative_path: analysis.relativePath - ? normalizeWorkspaceRelativePath(analysis.relativePath) - : analysis.relativePath, - }; - - let resolvedHtml = null; - if (analysis.contentMode === 'static_html') { - resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null); - } - const privacyScan = scanContent(resolvedHtml?.content ?? source.content); - - if (categoryCode !== 'draft') { - let buffer; - let filename; - let displayName = req.body?.title; - if (analysis.contentMode === 'static_html') { - if (!resolvedHtml) { - throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); - } - buffer = Buffer.from(resolvedHtml.content, 'utf8'); - filename = resolvedHtml.filename; - displayName = displayName || resolvedHtml.suggestedTitle; - } else { - buffer = Buffer.from(source.content, 'utf8'); - filename = `${String(displayName || 'chat-export') - .replace(/[^\w\u4e00-\u9fff-]+/g, '-') - .slice(0, 48) || 'chat-export'}.md`; - } - const asset = await mindSpaceAssets.createChatAsset(req.currentUser.id, { - categoryCode, - buffer, - filename, - displayName, - sourceType: 'chat', - }); - return res.status(201).json({ - data: { - kind: 'asset', - categoryCode, - asset, - }, - }); - } - - let pageInput = { - title: req.body?.title, - summary: req.body?.summary, - templateId: req.body?.template_id, - pageType: req.body?.page_type, - categoryCode: 'draft', - }; - if (analysis.contentMode === 'static_html') { - if (!resolvedHtml) { - throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); - } - pageInput = { - ...pageInput, - title: req.body?.title || resolvedHtml.suggestedTitle, - summary: req.body?.summary || resolvedHtml.suggestedSummary, - content: resolvedHtml.content, - contentFormat: 'html', - pageType: 'html', - }; - } else { - pageInput = { - ...pageInput, - content: source.content, - contentFormat: 'markdown', - }; - } - - if (analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath) { - const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser); - await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, { - title: pageInput.title, - subtitle: pageInput.summary, - }).catch(() => {}); - } - - const saveAsNew = Boolean(req.body?.save_as_new); - let replacePageId = req.body?.replace_page_id - ? String(req.body.replace_page_id).trim() - : null; - if (!replacePageId && !saveAsNew && analysis.contentMode === 'static_html' && analysis.relativePath) { - const existingByPath = await mindSpacePages - .findPageByRelativePath(req.currentUser.id, analysis.relativePath) - .catch(() => null); - if (existingByPath) replacePageId = existingByPath.id; - } - let page; - if (replacePageId) { - const existingPage = await mindSpacePages.getPage(req.currentUser.id, replacePageId); - page = await mindSpacePages.updatePage(req.currentUser.id, replacePageId, { - ...pageInput, - expectedVersion: existingPage.versionNo, - }); - } else { - page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, { - sessionId: req.body.session_id, - messageId: req.body.message_id, - snapshot, - }); - } - return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const page = await mindSpacePages.createPage(req.currentUser.id, { - title: req.body?.title, - summary: req.body?.summary, - content: req.body?.content, - contentFormat: req.body?.content_format === 'html' ? 'html' : undefined, - templateId: req.body?.template_id, - pageType: req.body?.page_type, - categoryCode: req.body?.category_code, - }); - return res.status(201).json({ data: page }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/pages', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - await syncUserGeneratedPages(req.currentUser.id); - const limit = Number.parseInt(String(req.query.limit ?? ''), 10); - const offset = Number.parseInt(String(req.query.offset ?? ''), 10); - const result = await mindSpacePages.listPages(req.currentUser.id, { - status: typeof req.query.status === 'string' ? req.query.status : undefined, - categoryCode: typeof req.query.category_code === 'string' ? req.query.category_code : undefined, - ...(Number.isFinite(limit) ? { limit } : {}), - ...(Number.isFinite(offset) ? { offset } : {}), - }); - return res.json({ - data: result.items, - page: { - total: result.total, - limit: result.limit, - offset: result.offset, - has_more: result.hasMore, - next_cursor: null, - }, - }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/pages/:pageId', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const [page, versions, publication] = await Promise.all([ - mindSpacePages.getPage(req.currentUser.id, req.params.pageId), - mindSpacePages.listVersions(req.currentUser.id, req.params.pageId), - mindSpacePublications?.getCurrent(req.currentUser.id, req.params.pageId) ?? null, - ]); - return res.json({ data: { ...page, versions, publication } }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.delete('/mindspace/v1/pages/:pageId', async (req, res) => { - if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return; - try { - const removeFromPlaza = - String(req.query?.remove_from_plaza ?? req.body?.remove_from_plaza ?? '').toLowerCase() === - 'true' || - req.query?.remove_from_plaza === '1' || - req.body?.remove_from_plaza === true; - const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId, { - removeFromPlaza, - }); - const quota = await mindSpace.getQuota(req.currentUser.id); - await mindSpaceAudit?.write({ - userId: req.currentUser.id, - action: 'page.delete', - objectType: 'page', - objectId: req.params.pageId, - ip: req.ip, - detail: { - offlinedPublicationCount: result.offlinedPublicationCount, - hiddenPlazaPostCount: result.hiddenPlazaPostCount, - deletedAssetCount: result.deletedAssetCount, - freedBytes: result.freedBytes, - }, - }); - return sendData(res, req, { ...result, quota }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/pages/:pageId/delete-preview', async (req, res) => { - if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return; - try { - return sendData(res, req, await mindSpacePages.getDeletePreview(req.currentUser.id, req.params.pageId)); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.put('/mindspace/v1/pages/:pageId', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const page = await mindSpacePages.updatePage(req.currentUser.id, req.params.pageId, { - expectedVersion: req.body?.expected_version, - title: req.body?.title, - summary: req.body?.summary, - content: req.body?.content, - templateId: req.body?.template_id, - pageType: req.body?.page_type, - changeNote: req.body?.change_note, - }); - return res.json({ data: page }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/:pageId/live-edit/bind', async (req, res) => { - if (!mindSpacePageLiveEdit || !mindSpacePages) { - return res.status(503).json({ message: 'MindSpace 未启用' }); - } - try { - const sessionId = String(req.body?.session_id ?? '').trim(); - if (!sessionId) { - return sendError(res, req, 400, 'invalid_request', '缺少 session_id'); - } - const owns = await ownsAgentSession(req.currentUser.id, sessionId); - if (!owns) { - return sendError(res, req, 403, 'forbidden', '无权绑定该 Agent 会话'); - } - await mindSpacePages.getPage(req.currentUser.id, req.params.pageId); - const parentSessionId = String(req.body?.parent_session_id ?? '').trim() || null; - return sendData( - res, - req, - mindSpacePageLiveEdit.bindSession({ - userId: req.currentUser.id, - sessionId, - pageId: req.params.pageId, - parentSessionId, - }), - ); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/:pageId/live-edit/fork-session', async (req, res) => { - if (!mindSpacePageEditSession || !mindSpacePages) { - return res.status(503).json({ message: 'MindSpace 未启用' }); - } - try { - const parentSessionId = String(req.body?.parent_session_id ?? '').trim(); - if (!parentSessionId) { - return sendError(res, req, 400, 'invalid_request', '缺少 parent_session_id'); - } - const gate = await userAuth.canUseChat(req.currentUser.id); - if (!gate.ok) { - return sendError(res, req, 402, gate.code ?? 'insufficient_balance', gate.message); - } - return sendData( - res, - req, - await mindSpacePageEditSession.forkSession({ - userId: req.currentUser.id, - pageId: req.params.pageId, - parentSessionId, - h5ApiBase: String(req.body?.h5_api_base ?? req.body?.h5ApiBase ?? '').trim() || null, - }), - ); - } catch (error) { - if (error?.code === 'forbidden') { - return sendError(res, req, 403, error.code, error.message); - } - if (error?.code === 'invalid_request') { - return sendError(res, req, 400, error.code, error.message); - } - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/:pageId/live-edit/close-session', async (req, res) => { - if (!mindSpacePageEditSession || !mindSpacePages) { - return res.status(503).json({ message: 'MindSpace 未启用' }); - } - try { - const sessionId = String(req.body?.session_id ?? '').trim(); - if (!sessionId) { - return sendError(res, req, 400, 'invalid_request', '缺少 session_id'); - } - return sendData( - res, - req, - await mindSpacePageEditSession.closeSession({ - userId: req.currentUser.id, - pageId: req.params.pageId, - sessionId, - parentSessionId: String(req.body?.parent_session_id ?? '').trim() || null, - summary: String(req.body?.summary ?? ''), - }), - ); - } catch (error) { - if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') { - return sendError(res, req, 403, error.code, error.message); - } - if (error?.code === 'invalid_request') { - return sendError(res, req, 400, error.code, error.message); - } - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/pages/:pageId/live-edit/revision', async (req, res) => { - if (!mindSpacePageLiveEdit || !mindSpacePages) { - return res.status(503).json({ message: 'MindSpace 未启用' }); - } - try { - return sendData( - res, - req, - await mindSpacePageLiveEdit.getRevisionSnapshot(req.currentUser.id, req.params.pageId), - ); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/agent/mindspace_page_patch', async (req, res) => { - if (!mindSpacePageLiveEdit) { - return res.status(503).json({ message: 'MindSpace 未启用' }); - } - try { - const result = await mindSpacePageLiveEdit.applyAgentPatch(req.body ?? {}); - return res.json({ - data: { - pageId: result.page.id, - title: result.page.title, - summary: result.page.summary, - versionNo: result.page.versionNo, - updatedAt: result.page.updatedAt, - liveRevision: result.liveRevision, - }, - }); - } catch (error) { - if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') { - return sendError(res, req, 403, error.code, error.message); - } - if (error?.code === 'invalid_request') { - return sendError(res, req, 400, error.code, error.message); - } - return mindSpaceError(res, req, error); - } -}); - -api.post('/agent/mindspace_asset_delete', async (req, res) => { - if (!mindSpaceAssetAgent) { - return res.status(503).json({ message: 'MindSpace 未启用' }); - } - try { - const result = await mindSpaceAssetAgent.applyAgentDelete(req.body ?? {}); - await mindSpaceAudit?.write({ - userId: result.userId ?? null, - action: 'asset.delete', - objectType: 'asset', - objectId: result.assetId, - ip: req.ip, - metadata: { via: 'agent' }, - }); - return sendData(res, req, result); - } catch (error) { - if (error?.code === 'forbidden') { - return sendError(res, req, 403, error.code, error.message); - } - if (error?.code === 'invalid_request' || error?.code === 'confirmation_required') { - return sendError(res, req, 400, error.code, error.message); - } - return mindSpaceError(res, req, error); - } -}); - -api.post('/agent/mindspace_asset_download', async (req, res) => { - if (!mindSpaceAssetAgent) { - return res.status(503).json({ message: 'MindSpace 未启用' }); - } - try { - const result = await mindSpaceAssetAgent.applyAgentDownload(req.body ?? {}); - await mindSpaceAudit?.write({ - userId: result.userId ?? null, - action: 'asset.download', - objectType: 'asset', - objectId: result.assetId, - ip: req.ip, - metadata: { via: 'agent', relativePath: result.relativePath ?? null }, - }); - return sendData(res, req, result); - } catch (error) { - if (error?.code === 'forbidden') { - return sendError(res, req, 403, error.code, error.message); - } - if (error?.code === 'invalid_request' || error?.code === 'asset_not_found') { - return sendError(res, req, 400, error.code, error.message); - } - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const svg = await mindSpacePages.renderThumbnail(req.currentUser.id, req.params.pageId); - res.set('Content-Type', 'image/svg+xml; charset=utf-8'); - res.set('Cache-Control', 'private, max-age=300'); - res.setHeader('X-Request-Id', req.requestId); - return res.send(svg); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/:pageId/thumbnail/upload', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const result = await mindSpacePages.uploadThumbnail(req.currentUser.id, req.params.pageId, { - imageBase64: req.body?.image_base64, - mimeType: req.body?.mime_type, - title: req.body?.title, - summary: req.body?.summary, - html: req.body?.html, - }); - return sendData(res, req, result); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/:pageId/thumbnail/regenerate', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const result = await mindSpacePages.regenerateThumbnail( - req.currentUser.id, - req.params.pageId, - { - html: req.body?.html, - title: req.body?.title, - summary: req.body?.summary, - useAi: Boolean(req.body?.use_ai), - instruction: req.body?.instruction, - }, - { - suggestCoverMeta: req.body?.use_ai - ? (input) => { - if (!authPool) { - throw Object.assign(new Error('LLM 服务未就绪'), { code: 'llm_not_configured' }); - } - return suggestCoverMetaWithAi(authPool, { - ...input, - encryptionKey: - process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY, - }); - } - : null, - }, - ); - return sendData(res, req, result); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/:pageId/rewrite-download-links', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const content = String(req.body?.content ?? '').trim(); - if (!content) { - throw Object.assign(new Error('缺少页面内容'), { code: 'invalid_page_input' }); - } - const html = await mindSpacePages.rewriteHtmlDownloadLinksForPage( - req.currentUser.id, - req.params.pageId, - content, - ); - return sendData(res, req, { html }); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/pages/:pageId/preview', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const { html, contentFormat } = await mindSpacePages.renderPreview( - req.currentUser.id, - req.params.pageId, - ); - res.set('Content-Type', 'text/html; charset=utf-8'); - res.set( - 'Content-Security-Policy', - pageInternals.previewContentSecurityPolicy(contentFormat), - ); - res.set('Cache-Control', 'private, no-store'); - res.set('X-Content-Type-Options', 'nosniff'); - return res.send(html); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/:pageId/preview-draft', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const { html, contentFormat } = await mindSpacePages.renderDraftPreview( - req.currentUser.id, - req.params.pageId, - { - title: req.body?.title, - summary: req.body?.summary, - content: req.body?.content, - templateId: req.body?.template_id, - }, - ); - res.set('Content-Type', 'text/html; charset=utf-8'); - res.set( - 'Content-Security-Policy', - pageInternals.previewContentSecurityPolicy(contentFormat), - ); - res.set('Cache-Control', 'private, no-store'); - res.set('X-Content-Type-Options', 'nosniff'); - return res.send(html); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/:pageId/publish-check', async (req, res) => { - if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const result = await mindSpacePublications.check(req.currentUser.id, req.params.pageId, { - pageVersionId: req.body?.page_version_id, - accessMode: req.body?.access_mode, - urlSlug: req.body?.url_slug, - password: req.body?.password, - expiresAt: req.body?.expires_at, - }); - return sendData(res, req, result); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/:pageId/redact', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const result = await mindSpacePages.redactPage(req.currentUser.id, req.params.pageId, { - pageVersionId: req.body?.page_version_id, - expectedVersion: req.body?.expected_version, - title: req.body?.title, - summary: req.body?.summary, - content: req.body?.content, - }); - await mindSpaceAudit?.write({ - userId: req.currentUser.id, - action: 'page.redact', - objectType: 'page', - objectId: result.page.id, - ip: req.ip, - riskLevel: result.originalScan.riskLevel, - }); - return sendData(res, req, result); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/:pageId/publish-fix', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const result = await mindSpacePages.localizePrivateResources(req.currentUser.id, req.params.pageId, { - pageVersionId: req.body?.page_version_id, - expectedVersion: req.body?.expected_version, - title: req.body?.title, - summary: req.body?.summary, - content: req.body?.content, - }); - await mindSpaceAudit?.write({ - userId: req.currentUser.id, - action: 'page.publish_fix', - objectType: 'page', - objectId: result.page.id, - ip: req.ip, - riskLevel: result.originalScan.riskLevel, - }); - return sendData(res, req, result); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/:pageId/redacted-copy', async (req, res) => { - if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const result = await mindSpacePages.redactPage(req.currentUser.id, req.params.pageId, { - pageVersionId: req.body?.page_version_id, - expectedVersion: req.body?.expected_version, - title: req.body?.title, - summary: req.body?.summary, - content: req.body?.content, - }); - await mindSpaceAudit?.write({ - userId: req.currentUser.id, - action: 'page.redact', - objectType: 'page', - objectId: result.page.id, - ip: req.ip, - riskLevel: result.originalScan.riskLevel, - }); - return sendData(res, req, result); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/pages/:pageId/publish', async (req, res) => { - if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const publication = await mindSpacePublications.publish( - req.currentUser.id, - req.params.pageId, - { - pageVersionId: req.body?.page_version_id, - accessMode: req.body?.access_mode, - urlSlug: req.body?.url_slug, - password: req.body?.password, - expiresAt: req.body?.expires_at, - acknowledgedFindingIds: req.body?.acknowledged_finding_ids, - }, - ); - const workspaceRoot = req.currentUser.workspaceRoot ?? null; - if (workspaceRoot) { - const syncedPolicy = syncPageDataPolicyAccessMode( - workspaceRoot, - req.params.pageId, - publication.accessMode, - req.currentUser.id, - ); - if (syncedPolicy) { - await upsertPageDataPolicyIndex(authPool, syncedPolicy).catch(() => null); - } - } - await mindSpaceAudit?.write({ - userId: req.currentUser.id, - action: 'page.publish', - objectType: 'publication', - objectId: publication.id, - ip: req.ip, - }); - return sendData(res, req, publication, 201); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/publications/:publicationId/update-status', async (req, res) => { - if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const publication = await mindSpacePublications.updatePublicationStatus( - req.currentUser.id, - req.params.publicationId, - { - accessMode: req.body?.access_mode, - expiresAt: req.body?.expires_at, - }, - ); - await mindSpaceAudit?.write({ - userId: req.currentUser.id, - action: 'publication.status_updated', - objectType: 'publication', - objectId: req.params.publicationId, - ip: req.ip, - }); - return sendData(res, req, publication); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.post('/mindspace/v1/publications/:publicationId/offline', async (req, res) => { - if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - const publication = await mindSpacePublications.offline( - req.currentUser.id, - req.params.publicationId, - ); - await mindSpaceAudit?.write({ - userId: req.currentUser.id, - action: 'page.offline', - objectType: 'publication', - objectId: req.params.publicationId, - ip: req.ip, - }); - return sendData(res, req, publication); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.get('/mindspace/v1/publications/:publicationId/stats', async (req, res) => { - if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); - try { - return sendData( - res, - req, - await mindSpacePublications.getStats( - req.currentUser.id, - req.params.publicationId, - ), - ); - } catch (error) { - return mindSpaceError(res, req, error); - } -}); - -api.get('/plaza/v1/categories', async (req, res) => { - if (!ensurePlazaEnabled(res, req)) return; - try { - return sendData(res, req, { categories: await plazaPosts.listCategories() }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.get('/plaza/v1/seo/sitemap', async (req, res) => { - if (!ensurePlazaEnabled(res, req)) return; - if (!plazaSeo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza SEO 未启用'); - try { - const data = await plazaSeo.listSitemapData({ - postLimit: req.query.post_limit, - userLimit: req.query.user_limit, - }); - return sendData(res, req, data); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.post('/plaza/v1/attribution/events', async (req, res) => { - if (!plazaSeo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); - try { - const result = await plazaSeo.recordAttribution(req.body ?? {}, plazaClientIp(req)); - return sendData(res, req, result, 201); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.post('/plaza/v1/posts/:id/reports', async (req, res) => { - if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); - if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - try { - const report = await plazaOps.createReport(req.currentUser.id, { - target_type: 'post', - target_id: req.params.id, - reason: req.body?.reason, - detail: req.body?.detail, - }); - return sendData(res, req, { report }, 201); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.post('/plaza/v1/comments/:id/reports', async (req, res) => { - if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); - if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - try { - const report = await plazaOps.createReport(req.currentUser.id, { - target_type: 'comment', - target_id: req.params.id, - reason: req.body?.reason, - detail: req.body?.detail, - }); - return sendData(res, req, { report }, 201); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.get('/plaza/v1/feed', async (req, res) => { - if (!ensurePlazaEnabled(res, req)) return; - try { - const sessionId = resolvePlazaSessionId(req, res); - const feed = await plazaPosts.listFeed({ - sort: req.query.sort, - categorySlug: req.query.category ?? null, - cursor: req.query.cursor ?? null, - limit: req.query.limit, - viewerId: req.currentUser?.id ?? null, - sessionId, - }); - return sendData(res, req, feed); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.post('/plaza/v1/events', async (req, res) => { - if (!ensurePlazaEnabled(res, req)) return; - if (!plazaEvents) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); - try { - const sessionId = - String(req.body?.session_id ?? '').trim() || resolvePlazaSessionId(req, res); - const result = await plazaEvents.recordEvents({ - userId: req.currentUser?.id ?? null, - sessionId, - events: req.body?.events ?? [], - }); - return sendData(res, req, { ...result, session_id: sessionId }, 201); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.post('/plaza/v1/posts/:id/reactions', async (req, res) => { - if (!ensurePlazaInteractions(res, req)) return; - if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - try { - const result = await plazaInteractions.addReaction( - req.currentUser.id, - req.params.id, - req.body?.type, - ); - const eventType = reactionEventType(result.type); - if (eventType) { - recordPlazaEventsAsync(req, res, [{ event_type: eventType, post_id: req.params.id }]); - } - return sendData(res, req, result); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => { - if (!ensurePlazaInteractions(res, req)) return; - if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - try { - const result = await plazaInteractions.removeReaction( - req.currentUser.id, - req.params.id, - req.params.type, - ); - return sendData(res, req, result); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.get('/plaza/v1/posts/:id/comments', async (req, res) => { - if (!ensurePlazaInteractions(res, req)) return; - try { - const comments = await plazaInteractions.listComments(req.params.id, { - cursor: req.query.cursor ?? null, - limit: req.query.limit, - parentId: req.query.parent_id ?? null, - viewerId: req.currentUser?.id ?? null, - }); - return sendData(res, req, comments); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.post('/plaza/v1/posts/:id/comments', async (req, res) => { - if (!ensurePlazaInteractions(res, req)) return; - if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - try { - const comment = await plazaInteractions.createComment(req.currentUser.id, req.params.id, req.body ?? {}); - recordPlazaEventsAsync(req, res, [{ event_type: 'comment', post_id: req.params.id }]); - return sendData(res, req, { comment }, 201); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.delete('/plaza/v1/comments/:id', async (req, res) => { - if (!ensurePlazaInteractions(res, req)) return; - if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - try { - const comment = await plazaInteractions.deleteComment(req.currentUser.id, req.params.id); - return sendData(res, req, { comment }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.post('/plaza/v1/comments/:id/reactions', async (req, res) => { - if (!ensurePlazaInteractions(res, req)) return; - if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - try { - const liked = req.body?.liked !== false; - const result = await plazaInteractions.toggleCommentLike(req.currentUser.id, req.params.id, liked); - return sendData(res, req, result); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.get('/plaza/v1/users/:slug', async (req, res) => { - if (!ensurePlazaInteractions(res, req)) return; - try { - const profile = await plazaInteractions.getUserProfile( - req.params.slug, - req.currentUser?.id ?? null, - ); - return sendData(res, req, profile); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.get('/plaza/v1/users/:slug/posts', async (req, res) => { - if (!ensurePlazaInteractions(res, req)) return; - try { - const feed = await plazaInteractions.listUserPosts(req.params.slug, { - cursor: req.query.cursor ?? null, - limit: req.query.limit, - viewerId: req.currentUser?.id ?? null, - }); - return sendData(res, req, feed); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.post('/plaza/v1/users/:slug/follow', async (req, res) => { - if (!ensurePlazaInteractions(res, req)) return; - if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - try { - const result = await plazaInteractions.followUser(req.currentUser.id, req.params.slug); - return sendData(res, req, result); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.delete('/plaza/v1/users/:slug/follow', async (req, res) => { - if (!ensurePlazaInteractions(res, req)) return; - if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - try { - const result = await plazaInteractions.unfollowUser(req.currentUser.id, req.params.slug); - return sendData(res, req, result); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.get('/plaza/v1/posts/:id', async (req, res) => { - if (!ensurePlazaEnabled(res, req)) return; - try { - const post = await plazaPosts.getPostById(req.params.id, { - viewerId: req.currentUser?.id ?? null, - }); - void plazaRedis.recordView(req.params.id, plazaClientIp(req)).catch(() => {}); - recordPlazaEventsAsync(req, res, [{ event_type: 'view', post_id: req.params.id }]); - return sendData(res, req, { post }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.post('/plaza/v1/posts', async (req, res) => { - if (!ensurePlazaEnabled(res, req)) return; - if (!req.currentUser) { - return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - } - try { - const post = await plazaPosts.createPost(req.currentUser.id, req.body ?? {}); - return sendData(res, req, { post }, 201); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.patch('/plaza/v1/posts/:id', async (req, res) => { - if (!ensurePlazaEnabled(res, req)) return; - if (!req.currentUser) { - return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - } - try { - const post = await plazaPosts.updatePost(req.currentUser.id, req.params.id, req.body ?? {}); - return sendData(res, req, { post }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -api.delete('/plaza/v1/posts/:id', async (req, res) => { - if (!ensurePlazaEnabled(res, req)) return; - if (!req.currentUser) { - return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - } - try { - const post = await plazaPosts.hidePost(req.currentUser.id, req.params.id); - return sendData(res, req, { post }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -function runHandlerChain(chain, req, res, next) { - let index = 0; - const run = (err) => { - if (err) return next(err); - const layer = chain[index++]; - if (!layer) return; - layer(req, res, (error) => run(error)); - }; - run(); -} - -api.post('/llm/apply-local-fallback', async (req, res) => { - // Client calls after creditsExhausted or relay 500 (see useTKMindChat). - await userAuthReady; - if (!userAuth || !llmProviderService || !tkmindProxy) { - return res.status(503).json({ message: '未启用 LLM 配置' }); - } - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const sessionId = String(req.body?.session_id ?? '').trim(); - if (!sessionId) return res.status(400).json({ message: '缺少 session_id' }); - const owns = await ownsAgentSession(me.id, sessionId); - if (!owns) return res.status(403).json({ message: '无权访问该会话' }); - try { - const result = await tkmindProxy.applyLocalFallbackForSession(sessionId); - if (!result.ok) return res.status(503).json(result); - res.json(result); - } catch (err) { - res.status(500).json({ - message: err instanceof Error ? err.message : '切换本地 LLM 失败', - }); - } -}); - -api.post('/agent/start', async (req, res, next) => { - await userAuthReady; - if (!userAuth || !tkmindProxy) return next(); - return runHandlerChain(tkmindProxy.handlers['POST /agent/start'], req, res, next); -}); - -api.post('/agent/runs', async (req, res, next) => { - await userAuthReady; - if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); - return runHandlerChain( - [ - tkmindProxy.requireUser, - tkmindProxy.ensureChatAllowed, - createPostAgentRunsHandler({ - userAuth, - sessionAccess, - agentRunGateway, - mindSpaceAssetAgent, - codeRunPolicyService: agentCodeRunPolicyService, - }), - ], - req, - res, - next, - ); -}); - -api.get('/agent/runs/:runId', async (req, res, next) => { - await userAuthReady; - if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); - return runHandlerChain( - [ - tkmindProxy.requireUser, - createGetAgentRunHandler({ agentRunGateway }), - ], - req, - res, - next, - ); -}); - -api.get('/agent/runs/:runId/events', async (req, res, next) => { - await userAuthReady; - if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); - return runHandlerChain( - [ - tkmindProxy.requireUser, - createAgentRunEventsHandler({ agentRunGateway }), - ], - req, - res, - next, - ); -}); - -api.post('/agent/resume', async (req, res, next) => { - await userAuthReady; - if (!userAuth || !tkmindProxy) return next(); - return runHandlerChain(tkmindProxy.handlers['POST /agent/resume'], req, res, next); -}); - -api.get('/sessions', async (req, res, next) => { - await userAuthReady; - if (!userAuth || !tkmindProxy) return next(); - return runHandlerChain(tkmindProxy.handlers['GET /sessions'], req, res, next); -}); - -// Session detail — serve from DB snapshot cache when fresh, fall through to Goose on miss. -api.get('/sessions/:sessionId', async (req, res, next) => { - await userAuthReady; - if (!userAuth || !tkmindProxy) return next(); - const sessionId = req.params.sessionId; - const owns = await ownsAgentSession(req.currentUser.id, sessionId); - if (!owns) { - return res.status(403).json({ message: '无权访问该会话' }); - } - // Hints from the client (session list already has these values). - const hintMc = req.query.hint_mc ? Number(req.query.hint_mc) : null; - const hintUa = req.query.hint_ua ? String(req.query.hint_ua) : null; - - try { - if (sessionSnapshotService?.isEnabled()) { - let snapshot = await sessionSnapshotService.get(sessionId); - if (snapshot) { - if (authPool && await shouldExpirePortalDirectChatSnapshot(authPool, sessionId, snapshot)) { - await sessionSnapshotService.remove(sessionId).catch(() => {}); - snapshot = null; - } - } - if (snapshot) { - // REGRESSION GUARD: without both hints, stale snapshot can wipe mid-turn chat. - const canUseSnapshotCache = hintMc != null && hintUa != null; - const mcMatch = snapshot.meta.synced_msg_count === hintMc; - const uaMatch = snapshot.meta.source_updated_at === hintUa; - if ( - isDirectChatSessionId(sessionId) || - isPortalDirectChatSnapshot(snapshot, { sessionId }) || - (canUseSnapshotCache && mcMatch && uaMatch) - ) { - const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks( - snapshot.messages, - req.currentUser, - ); - // Cache hit — reconstruct a Goose-compatible session response. - let cachedGooseSession = { - ...snapshot.session, - // Embed only userVisible messages so getSession callers still work. - conversation: sanitizedMessages, - }; - if (authPool) { - cachedGooseSession = await repairSessionConversationFromDb( - authPool, - cachedGooseSession, - sessionId, - req.currentUser.id, - ); - } - return res.json(cachedGooseSession); - } - } - } - } catch { - // Snapshot read error: fall through silently to Goose. - } - - // Cache miss — proxy to Goose and write-through on success. - try { - const target = await tkmindProxy.resolveTarget(sessionId); - const upstream = await tkmindProxy.apiFetchTo( - target, - `/sessions/${encodeURIComponent(sessionId)}`, - { method: 'GET' }, - ); - if (!upstream.ok) { - const text = await upstream.text().catch(() => ''); - return res.status(upstream.status).send(text); - } - let gooseSession = await upstream.json(); - if (Array.isArray(gooseSession.conversation)) { - gooseSession.conversation = sanitizeSessionConversationPublicHtmlLinks( - gooseSession.conversation, - req.currentUser, - ); - } - if (authPool) { - gooseSession = await repairSessionConversationFromDb( - authPool, - gooseSession, - sessionId, - req.currentUser.id, - ); - } - // Write-through: persist snapshot async, don't block the response. - if (sessionSnapshotService?.isEnabled()) { - const messages = filterNonemptyUserVisibleMessages(gooseSession.conversation ?? []); - if (messages.length > 0) { - void sessionSnapshotService - .save(sessionId, req.currentUser.id, gooseSession, messages) - .catch(() => {}); - } - } - return res.json(gooseSession); - } catch (err) { - return res.status(502).json({ message: err instanceof Error ? err.message : '读取会话失败' }); - } -}); - -api.delete('/sessions/:sessionId', async (req, res, next) => { - await userAuthReady; - if (!userAuth || !tkmindProxy) return next(); - const sessionId = req.params.sessionId; - const owns = await ownsAgentSession(req.currentUser.id, sessionId); - if (!owns) { - return res.status(403).json({ message: '无权访问该会话' }); - } - try { - if (isDirectChatSessionId(sessionId)) { - await unregisterAgentSessionForUser(req.currentUser.id, sessionId); - void sessionSnapshotService?.remove(sessionId).catch(() => {}); - return res.status(204).end(); - } - const deleteTarget = await tkmindProxy.resolveTarget(sessionId); - const upstream = await tkmindProxy.apiFetchTo(deleteTarget, `/sessions/${encodeURIComponent(sessionId)}`, { - method: 'DELETE', - }); - if (!upstream.ok && upstream.status !== 404) { - const text = await upstream.text().catch(() => ''); - return res.status(upstream.status).send(text || '删除会话失败'); - } - await unregisterAgentSessionForUser(req.currentUser.id, sessionId); - // Remove snapshot so it doesn't linger after deletion. - void sessionSnapshotService?.remove(sessionId).catch(() => {}); - return res.status(204).end(); - } catch (err) { - return res.status(500).json({ message: err instanceof Error ? err.message : '删除会话失败' }); - } -}); - -api.get('/sessions/:sessionId/events', async (req, res, next) => { - await userAuthReady; - if (!userAuth || !tkmindProxy) return next(); - const sessionId = req.params.sessionId; - const owns = await ownsAgentSession(req.currentUser.id, sessionId); - if (!owns) { - return res.status(403).json({ message: '无权访问该会话' }); - } - if (isDirectChatSessionId(sessionId)) { - const snapshot = await sessionSnapshotService?.get(sessionId).catch(() => null); - if (!snapshot) { - return res.status(404).json({ message: '会话不存在' }); - } - return sendDirectChatSessionEvents(req, res, snapshot); - } - const portalDirectSnapshot = await sessionSnapshotService?.get(sessionId).catch(() => null); - if ( - portalDirectSnapshot && - authPool && - await shouldExpirePortalDirectChatSnapshot(authPool, sessionId, portalDirectSnapshot) - ) { - await sessionSnapshotService?.remove(sessionId).catch(() => {}); - } else if (portalDirectSnapshot && isPortalDirectChatSnapshot(portalDirectSnapshot, { sessionId })) { - return sendDirectChatSessionEvents(req, res, portalDirectSnapshot); - } - const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: req.currentUser.id }); - // `proxySessionEvents` deliberately invokes `onEvent` synchronously so an - // async callback here would leave rejected database writes unhandled. - // Retain every in-flight contract write and await it before marking files - // deliverable after Finish. - const deliveryContractWrites = new Map(); - const generationAnalyticsEvents = new Set(); - const syncPublicHtmlDuringStream = (event) => { - const paths = collectPublicHtmlWritePathsFromSessionEvent(event, { publishDir }); - const eventMessages = event?.type === 'Message' && event.message - ? [event.message] - : event?.type === 'UpdateConversation' && Array.isArray(event.conversation) - ? event.conversation - : []; - const pgRequired = eventMessages.some( - (message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true, - ); - for (const relativePath of paths) { - if (deliveryContractWrites.has(relativePath)) continue; - const write = preparePageDeliveryContract({ - pool: authPool, - userId: req.currentUser.id, - requestId: sid, - relativePath, - pgRequired, - }).catch((error) => { - console.warn(`[MindSpace] failed to prepare delivery contract for ${relativePath}: ${error?.message || error}`); - return null; - }); - deliveryContractWrites.set(relativePath, write); - } - materializePublicHtmlWritesFromSessionEvent(event, { publishDir }); - for (const relativePath of paths) { - const absolutePath = path.resolve(publishDir, relativePath); - if (generationAnalyticsEvents.has(relativePath) || !fs.existsSync(absolutePath)) continue; - generationAnalyticsEvents.add(relativePath); - void sendMindSpaceAnalyticsEvent({ - config: mindSpaceAnalyticsConfig, - eventName: 'page_generated', - ownerId: req.currentUser.id, - ownerSegment: resolveAnalyticsOwnerSegment(req.currentUser), - ownerLabel: resolveAnalyticsOwnerLabel(req.currentUser), - pageId: relativePath, - publicationId: sid, - agentRunId: sid, - channel: 'h5', - url: `/${PUBLISH_ROOT_DIR}/${req.currentUser.id}/${relativePath}`, - }); - } - }; - // After Finish, refresh the snapshot and persist any newly generated public - // workspace HTML into the asset store before a later restart rebuilds the - // workspace from DB-backed assets only. - const onAfterFinish = async (sid, uid) => { - beginSessionPageDelivery(sid); - try { - const apiFetchFn = async (pathname, init) => { - const target = await tkmindProxy.resolveTarget(sid); - return tkmindProxy.apiFetchTo(target, pathname, init); - }; - let messages = null; - if (sessionSnapshotService?.isEnabled()) { - await sessionSnapshotService.refresh(sid, uid, apiFetchFn); - messages = (await sessionSnapshotService.get(sid))?.messages ?? null; - } else { - try { - const upstream = await apiFetchFn(`/sessions/${encodeURIComponent(sid)}`, { method: 'GET' }); - if (upstream.ok) { - const payload = await upstream.json().catch(() => null); - messages = Array.isArray(payload?.conversation) - ? payload.conversation.filter((message) => message?.metadata?.userVisible) - : null; - } - } catch { - messages = null; - } - } - const syncResult = await syncPublicHtmlAfterFinish({ - messages, - currentUser: req.currentUser, - publishDir, - sessionId: sid, - pool: authPool, - storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot, - h5Root: __dirname, - syncWorkspaceAssets: - WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets - ? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options) - : null, - registerPublicHtmlArtifacts: (_userId, options) => - registerPublicHtmlArtifactsForConversation({ - user: req.currentUser, - publishDir, - sessionId: options?.sessionId, - relativePaths: options?.relativePaths, - artifactRefs: options?.artifactRefs, - }), - }); - if (Array.isArray(syncResult?.docxSync?.missing) && syncResult.docxSync.missing.length > 0) { - console.warn( - `[MindSpace] missing public download files after finish for user ${uid}: ${syncResult.docxSync.missing.join(', ')}`, - ); - } - const htmlDelivery = await maybeRepairH5HtmlAfterFinish({ - sessionId: sid, - userId: uid, - currentUser: req.currentUser, - messages, - publishDir, - syncResult, - tkmindProxy, - }); - const lastUserMessage = [...(Array.isArray(messages) ? messages : [])] - .reverse() - .find((message) => message?.role === 'user'); - const lastUserText = - typeof lastUserMessage?.content === 'string' - ? lastUserMessage.content - : Array.isArray(lastUserMessage?.content) - ? lastUserMessage.content - .filter((item) => item?.type === 'text') - .map((item) => String(item.text ?? '').trim()) - .filter(Boolean) - .join('\n') - : ''; - await syncUserGeneratedPages(uid, { sessionId: sid }); - const pageDataDelivery = await maybeRepairPageDataAfterFinish({ - sessionId: sid, - userId: uid, - publishDir, - messages, - pool: authPool, - h5Root: __dirname, - storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot, - tkmindProxy, - userText: lastUserText, - }); - const htmlReady = htmlDelivery?.skipped === 'ok'; - const pageDataReady = ['ok', 'not_page_data'].includes(String(pageDataDelivery?.skipped ?? '')); - if (htmlReady && pageDataReady) { - const publicHtmlRelativePaths = syncResult?.publicHtmlRelativePaths ?? []; - const pgRequired = [...(Array.isArray(messages) ? messages : [])].some( - (message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true, - ); - for (const relativePath of publicHtmlRelativePaths) { - // A Finish-only write may not have reached the stream callback. This - // also upgrades an early partial stream contract with the definitive - // user delivery choice before it becomes ready. - await (deliveryContractWrites.get(relativePath) ?? preparePageDeliveryContract({ - pool: authPool, - userId: uid, - requestId: sid, - relativePath, - pgRequired, - })); - if (deliveryContractWrites.has(relativePath)) { - await preparePageDeliveryContract({ - pool: authPool, - userId: uid, - requestId: sid, - relativePath, - pgRequired, - }); - } - await markPageDeliveryContractReady({ - pool: authPool, - userId: uid, - relativePath, - }).catch(() => false); - } - } - if (lastUserMessage && memoryV2?.observePersonalMemory) { - await memoryV2.observePersonalMemory({ - userId: uid, - sessionId: sid, - messages: [lastUserMessage], - }).catch((err) => { - console.warn( - `[memory-v2] finish shadow observation skipped for session ${sid}: ${err instanceof Error ? err.message : err}`, - ); - }); - } - } finally { - endSessionPageDelivery(sid); - } - }; - return tkmindProxy.proxySessionEvents(req, res, sessionId, { - onAfterFinish, - onEvent: syncPublicHtmlDuringStream, - }); +attachPortalMindSpaceChatSaveRoutes(api, { + h5Root: H5_ROOT, + env: process.env, + getMindSpacePages: () => mindSpacePages, + getMindSpaceAssets: () => mindSpaceAssets, + getAuthPool: () => authPool, + getConversationPackageRegistry: () => + mindSpaceConversationPackageRegistry, + resolveChatSaveBundle, + resolveExistingSavedPage, + resolveOwnedAssistantMessage, + sendData, + handleMindSpaceError: mindSpaceError, +}); + +attachPortalMindSpaceChatShareRoutes(api, { + h5Root: H5_ROOT, + env: process.env, + getMindSpacePages: () => mindSpacePages, + getMindSpacePublications: () => mindSpacePublications, + getPlazaPosts: () => plazaPosts, + getAuthPool: () => authPool, + resolveChatSaveBundle, + resolvePlazaPostUrl: resolvePlazaPostUrlForRequest, + mapPlazaError, + sendData, + sendError, + handlePlazaError: plazaRouteError, + handleMindSpaceError: mindSpaceError, + logger: console, +}); + +attachPortalMindSpacePageCoreRoutes(api, { + getMindSpacePages: () => mindSpacePages, + getMindSpacePublications: () => mindSpacePublications, + getMindSpace: () => mindSpace, + getMindSpaceAudit: () => mindSpaceAudit, + getMindSpacePageLiveEdit: () => mindSpacePageLiveEdit, + getMindSpacePageEditSession: () => mindSpacePageEditSession, + getUserAuth: () => userAuth, + syncUserGeneratedPages, + ownsAgentSession, + ensureMindSpaceEnabled, + sendData, + sendError, + handleMindSpaceError: mindSpaceError, +}); + +attachPortalMindSpaceAgentOperationRoutes(api, { + getMindSpacePageLiveEdit: () => mindSpacePageLiveEdit, + getMindSpaceAssetAgent: () => mindSpaceAssetAgent, + getMindSpaceAudit: () => mindSpaceAudit, + sendData, + sendError, + handleMindSpaceError: mindSpaceError, +}); + +attachPortalMindSpacePagePublishRoutes(api, { + env: process.env, + getMindSpacePages: () => mindSpacePages, + getMindSpacePublications: () => mindSpacePublications, + getMindSpaceAudit: () => mindSpaceAudit, + getAuthPool: () => authPool, + sendData, + handleMindSpaceError: mindSpaceError, +}); + +attachPortalPlazaDiscoveryRoutes(api, { + getPlazaPosts: () => plazaPosts, + getPlazaSeo: () => plazaSeo, + sendData, + sendError, + handleRouteError: plazaRouteError, +}); + +attachPortalPlazaRoutes(api, { + getPlazaSeo: () => plazaSeo, + getPlazaOps: () => plazaOps, + getPlazaPosts: () => plazaPosts, + getPlazaEvents: () => plazaEvents, + getPlazaInteractions: () => plazaInteractions, + getPlazaRedis: () => plazaRedis, + resolveClientIp: plazaClientIp, + sendData, + sendError, + handleRouteError: plazaRouteError, +}); + +attachPortalAgentRuntimeRoutes(api, { + waitForUserAuthReady: () => userAuthReady, + getUserAuth: () => userAuth, + getTkmindProxy: () => tkmindProxy, + getLlmProviderService: () => llmProviderService, + getAgentRunGateway: () => agentRunGateway, + getSessionAccess: () => sessionAccess, + getMindSpaceAssetAgent: () => mindSpaceAssetAgent, + getCodeRunPolicyService: () => agentCodeRunPolicyService, + getUserToken: userToken, + ownsAgentSession, +}); + +attachPortalSessionRoutes(api, { + h5Root: H5_ROOT, + env: process.env, + waitForUserAuthReady: () => userAuthReady, + getUserAuth: () => userAuth, + getTkmindProxy: () => tkmindProxy, + getSessionSnapshotService: () => sessionSnapshotService, + getAuthPool: () => authPool, + getMindSpaceAssets: () => mindSpaceAssets, + getMemoryV2: () => memoryV2, + getMindSpaceAnalyticsConfig: () => mindSpaceAnalyticsConfig, + isWorkspaceMaintenanceEnabled: () => + WORKSPACE_MAINTENANCE_ENABLED, + ownsAgentSession, + unregisterAgentSessionForUser, + beginSessionPageDelivery, + endSessionPageDelivery, + syncUserGeneratedPages, + registerPublicHtmlArtifactsForConversation, + logger: console, }); api.use(apiRequestBodyError); @@ -5582,6 +1475,20 @@ app.use('/analytics', createProxyMiddleware({ secure: false, pathRewrite: { [`^${mindSpaceAnalyticsConfig.hostPath}`]: '' }, })); +const { serveUserPublishFile } = + createPortalWorkspacePublicationDelivery({ + h5Root: H5_ROOT, + internalAgentSecret: + INTERNAL_AGENT_SECRET, + analyticsConfig: + mindSpaceAnalyticsConfig, + getAuthPool: () => authPool, + getUserAuth: () => userAuth, + getMindSpacePages: () => mindSpacePages, + resolveRequestOrigin, + removeQueryParam, + logger: console, + }); // Express routing is case-insensitive by default, so the lowercase /mindspace API // mount would otherwise capture public /MindSpace/... page URLs. app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => { @@ -5590,1086 +1497,37 @@ app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => { }); app.use('/mindspace', api); -function extractOgImageUrl(html) { - return ( - String(html ?? '').match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] || - String(html ?? '').match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)?.[1] || - '' - ); -} - -function extractShareMetaFromPageHtml(html) { - const signals = extractCoverSignals(String(html ?? '')); - return { - title: detectPublishedPageTitle(html), - subtitle: signals.subtitle, - }; -} - -function appendQueryParam(url, key, value) { - const separator = url.includes('?') ? '&' : '?'; - return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`; -} - -function removeQueryParam(url, key) { - if (!url || !url.includes('?')) return url; - const [base, queryAndHash] = url.split('?', 2); - const [query, hash = ''] = queryAndHash.split('#', 2); - const params = new URLSearchParams(query); - params.delete(key); - const nextQuery = params.toString(); - return `${base}${nextQuery ? `?${nextQuery}` : ''}${hash ? `#${hash}` : ''}`; -} - -function resolveRequestOrigin(req) { - return resolvePublicRequestOrigin({ - hostHeader: req.headers['x-forwarded-host'] || req.headers.host || '', - forwardedProto: req.headers['x-forwarded-proto'] || '', - protocol: req.protocol, - }); -} - -function detectPublishedPageTitle(html) { - const match = String(html ?? '').match(/]*>([^<]+)<\/title>/i); - return match?.[1]?.replace(/\s+/g, ' ').trim() || 'MindSpace 页面'; -} - -function publishedPageShellHtml({ iframeUrl, shareUrl, title, longImageUrl }) { - const iframeSrc = escapePublicHtml(iframeUrl); - const safeShareUrl = escapePublicHtml(shareUrl); - const safeTitle = escapePublicHtml(title); - const serializedShareUrl = JSON.stringify(shareUrl).replace(/ - - - - - - ${safeTitle} - - - -
- - -
- - - - -`; -} - -async function sendPublishedPage(req, res, result, { embed = false, raw = false, ownerSlug = null } = {}) { - let html = rewriteBrokenMindSpacePublicImageUrls(result.html); - if (ownerSlug) { - html = rewritePublicationCanonicalAssetUrls(html, ownerSlug); - } - const origin = resolveRequestOrigin(req); - const originalPath = req.originalUrl || req.url || ''; - const sharePath = removeQueryParam(removeQueryParam(originalPath, 'download'), 'export'); - const pageUrl = originalPath ? new URL(sharePath, origin || 'http://localhost').toString().split('#')[0] : ''; - const pageDirUrl = pageUrl ? `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}` : ''; - const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || ''); - if (embed) { - html = preparePublicationHtmlForEmbed(html); - allowPlazaEmbedFrame(res); - } else if (raw) { - html = stripPublicationHtmlCspMeta(html); - } - html = injectPublishedPageDataContext(html, { - pageSource: result.pageSource, - publication: result.publication, - }); - if (!embed) { - try { - html = injectOgTags(html, { origin, pageUrl, pageDirUrl }); - if (wechatShare) html = injectWechatShareBridge(html, { pageUrl }); - } catch { - // Never block public page delivery on share metadata injection. - } - } - const isFullHtml = /^\s*]/i.test(html); - if (!embed && !raw && isFullHtml && isLongImageDownloadRequest(req.query)) { - try { - const rawUrl = new URL(appendQueryParam(sharePath || originalPath, 'view', 'raw'), origin || 'http://localhost'); - const image = await renderLongImageBuffer({ url: rawUrl.toString() }); - const longImageUrl = new URL( - appendQueryParam(sharePath || originalPath, 'download', 'long-image'), - origin || 'http://localhost', - ).toString(); - await registerPublishedLongImageArtifactForConversation({ - result, - image, - canonicalUrl: longImageUrl, - }); - res.set('Content-Type', 'image/png'); - res.set('Content-Disposition', 'attachment; filename="mindspace-public-page.long.png"'); - res.set('Cache-Control', 'no-store'); - return res.send(image); - } catch (error) { - return res - .status(500) - .type('text/plain; charset=utf-8') - .send(`长图生成失败:${error?.message || '未知错误'}`); - } - } - const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password'; - if (canWrapWithShell) { - const title = detectPublishedPageTitle(html); - const rawUrl = appendQueryParam(sharePath || originalPath, 'view', 'raw'); - const longImageUrl = appendQueryParam(sharePath || originalPath, 'download', 'long-image'); - let shellHtml = publishedPageShellHtml({ - iframeUrl: rawUrl, - shareUrl: pageUrl, - title, - longImageUrl, - }); - try { - shellHtml = injectOgTags(shellHtml, { - origin, - pageUrl, - pageDirUrl, - fallbackImageUrl: extractOgImageUrl(html), - meta: extractShareMetaFromPageHtml(html), - }); - if (wechatShare) shellHtml = injectWechatShareBridge(shellHtml, { pageUrl }); - } catch { - // Keep the share shell usable even if metadata injection fails. - } - res.set('Content-Type', 'text/html; charset=utf-8'); - res.set( - 'Content-Security-Policy', - wechatShare - ? "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net https://res.wx.qq.com; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" - : "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", - ); - res.set( - 'Cache-Control', - result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store', - ); - return res.send(shellHtml); - } - res.set('Content-Type', 'text/html; charset=utf-8'); - res.set( - 'Content-Security-Policy', - publishedPageCsp(html, { - embed, - raw, - wechatShare, - scriptHashes: collectInlineScriptHashes(html), - }), - ); - res.set( - 'Cache-Control', - result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store', - ); - return res.send(html); -} - -function passwordGateHtml(action) { - return ` - - - - - - 受保护页面 - - - -
-

此页面受密码保护

-

请输入发布者提供的访问密码。密码不会写入链接或浏览器日志。

- - -
- -`; -} - -function escapePublicHtml(value) { - return String(value ?? '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -function publicHomepageHtml(data) { - const cards = data.pages - .map( - (page) => `\ - -
- ${escapePublicHtml(page.templateId)} - ${Number(page.viewCount)} 次浏览 -
-

${escapePublicHtml(page.title)}

-

${escapePublicHtml(page.summary || '暂无摘要')}

-
- ${new Date(page.publishedAt).toLocaleDateString('zh-CN')} - 打开页面 -
-
`, - ) - .join(''); - return ` - - - - - - ${escapePublicHtml(data.owner.displayName)} · MindSpace - - - -
-
-

MindSpace Public

-

${escapePublicHtml(data.owner.displayName)}

-

这里展示 ${escapePublicHtml(data.owner.displayName)} 当前公开发布且在线的 MindSpace 页面。

-
- ${Number(data.pageCount)} 个公开页面 - ${Number(data.totalViews)} 次累计浏览 - /u/${escapePublicHtml(data.owner.slug)} -
-
- ${ - data.pages.length - ? `
${cards}
` - : '
这个主页还没有公开页面。稍后再来看看,或者直接访问作者分享给你的专属链接。
' - } -
- -`; -} - -async function resolvePublishedRoute(req, res, password = null) { - await userAuthReady; - if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用'); - try { - const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null; - const result = await mindSpacePublications.resolvePublic( - req.params.ownerSlug, - req.params.urlSlug, - viewer?.id, - password, - { - userAgent: req.get('user-agent'), - referrer: req.get('referer'), - }, - ); - const embed = isPlazaEmbedRequest(req.query); - const raw = String(req.query.view ?? '').toLowerCase() === 'raw'; - if ( - !password && - !embed && - !raw && - result.publication?.accessMode === 'public' && - isPublicWorkspaceHtmlRelativePath(result.workspaceRelativePath) - ) { - const redirectPath = buildMindSpacePublicRoutePath( - result.ownerId, - String(result.workspaceRelativePath).split('/'), - ); - return res.redirect(301, redirectPath); - } - return await sendPublishedPage(req, res, result, { - embed, - raw, - ownerSlug: req.params.ownerSlug, - }); - } catch (error) { - if (error?.code === 'publication_password_required') { - return res.status(password ? 403 : 200).send(passwordGateHtml(req.originalUrl)); - } - if (error?.code === 'publication_login_required') { - return res.status(401).send('请先登录 TKMind 后再访问此页面'); - } - if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线'); - return res.status(500).send('页面加载失败'); - } -} - -app.use(async (req, res, next) => { - const thumbnailMatch = /^\/u\/([^/]+)\/pages\/([^/]+)\.thumbnail\.png$/.exec(req.path); - if (!thumbnailMatch) return next(); - const ownerSlug = thumbnailMatch[1]; - const urlSlug = thumbnailMatch[2]; - await userAuthReady; - if (!authPool || !mindSpacePages) return res.status(503).send('MindSpace 未启用'); - try { - const [rows] = await authPool.query( - `SELECT pr.user_id, pr.page_id - FROM h5_publish_records pr - JOIN h5_users u ON u.id = pr.user_id - WHERE COALESCE(u.slug, u.username) = ? - AND pr.url_slug = ? - AND pr.status = 'online' - ORDER BY pr.published_at DESC - LIMIT 1`, - [ownerSlug, urlSlug], - ); - const row = rows[0]; - if (!row) return res.status(404).send('缩略图不存在'); - const svg = await mindSpacePages.renderThumbnail(row.user_id, row.page_id); - res.set('Content-Type', 'image/png'); - res.set('Cache-Control', 'public, max-age=300'); - return res.send(rasterizeThumbnailSvgToPng(svg)); - } catch (error) { - return res.status(500).send('缩略图加载失败'); - } -}); - -app.get('/u/:ownerSlug/pages/:urlSlug', async (req, res) => { - return resolvePublishedRoute(req, res); -}); - -app.use('/u/:ownerSlug/public', async (req, res) => { - await userAuthReady; - const ownerSlug = String(req.params.ownerSlug ?? '').trim().toLowerCase(); - if (!ownerSlug) return res.status(404).json({ message: '用户不存在' }); - if (!authPool) return res.status(503).send('MindSpace 未启用'); - - try { - const [rows] = await authPool.query( - `SELECT id FROM h5_users WHERE LOWER(COALESCE(slug, username)) = ? LIMIT 1`, - [ownerSlug], - ); - const userId = rows[0]?.id ? String(rows[0].id) : null; - if (!userId) return res.status(404).json({ message: '用户不存在' }); - - const relativePath = String(req.path ?? '') - .replace(/^\/+/, '') - .split('/') - .map((segment) => decodeURIComponent(segment)) - .filter(Boolean); - if (relativePath.length === 0) return res.status(404).json({ message: '文件不存在' }); - - const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId }); - const resolvedRoot = path.resolve(publishDir); - const filePath = path.resolve(publishDir, PUBLIC_ZONE_DIR, ...relativePath); - if (filePath !== resolvedRoot && !filePath.startsWith(`${resolvedRoot}${path.sep}`)) { - return res.status(403).json({ message: '禁止访问' }); - } - if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { - return res.status(404).json({ message: '文件不存在' }); - } - - return res.sendFile(filePath, (err) => { - if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' }); - }); - } catch { - return res.status(500).send('文件加载失败'); - } -}); - -app.get('/u/:ownerSlug', async (req, res) => { - await userAuthReady; - if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用'); - try { - const data = await mindSpacePublications.getPublicHomepage(req.params.ownerSlug); - res.set('Content-Type', 'text/html; charset=utf-8'); - res.set( - 'Content-Security-Policy', - "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", - ); - res.set('Cache-Control', 'public, max-age=60'); - return res.send(publicHomepageHtml(data)); - } catch (error) { - if (error?.code === 'publication_not_found') return res.status(404).send('主页不存在'); - return res.status(500).send('主页加载失败'); - } -}); - -app.post( - '/u/:ownerSlug/pages/:urlSlug', - express.urlencoded({ extended: false, limit: '2kb' }), - async (req, res) => resolvePublishedRoute(req, res, req.body?.password), -); - -app.get('/s/:token', async (req, res) => { - await userAuthReady; - if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用'); - try { - const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null; - return await sendPublishedPage( - req, - res, - await mindSpacePublications.resolvePrivateLink(req.params.token, viewer?.id, { - userAgent: req.get('user-agent'), - referrer: req.get('referer'), - }), - { - embed: isPlazaEmbedRequest(req.query), - raw: String(req.query.view ?? '').toLowerCase() === 'raw', - }, - ); - } catch (error) { - if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线'); - return res.status(500).send('页面加载失败'); - } -}); - -/** - * Send a file, injecting Open Graph tags for .html so forwarded links unfurl with a cover. - * Non-HTML files (assets, etc.) are streamed unchanged via res.sendFile. - */ -async function sendLongImageDownloadIfRequested(req, res, filePath) { - const origin = resolveRequestOrigin(req); - const currentUrl = origin ? `${origin}${req.originalUrl || req.url || ''}` : null; - return handleMindSpaceLongImageDownload({ - query: req.query, - filePath, - pageUrl: currentUrl ? removeQueryParam(removeQueryParam(currentUrl, 'download'), 'export') : null, - res, - isLongImageDownloadRequest, - longImagePathForHtml, - renderLongImage, - }); -} - -async function ensurePublicHtmlPrivateAssetsMaterialized(filePath, html) { - if (!authPool || !htmlReferencesPrivateAssets(html)) return html; - const normalized = path.resolve(String(filePath ?? '')); - const ownerMatch = normalized.match( - new RegExp(`[\\\\/]${PUBLISH_ROOT_DIR}[\\\\/]([0-9a-f-]{36})[\\\\/]`, 'i'), - ); - const userId = ownerMatch?.[1] ?? null; - if (!userId) return html; - const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId }); - const htmlRelativePath = path.relative(publishDir, normalized).replace(/\\/g, '/'); - if (!htmlRelativePath.startsWith(`${PUBLIC_ZONE_DIR}/`)) return html; - const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env); - const result = await materializePrivateAssetsInWorkspaceHtml({ - pool: authPool, - storageRoot, - h5Root: __dirname, - userId, - html, - htmlRelativePath, - writeBack: true, - }).catch(() => ({ html, changed: false })); - return result.changed ? result.html : html; -} - -async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) { - if (!filePath.toLowerCase().endsWith('.html')) { - res.sendFile(filePath, (err) => { - if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' }); - }); - return; - } - if (await sendLongImageDownloadIfRequested(req, res, filePath)) return; - let html; - try { - html = fs.readFileSync(filePath, 'utf8'); - } catch { - res.status(404).json({ message: '文件不存在' }); - return; - } - html = await ensurePublicHtmlPrivateAssetsMaterialized(filePath, html); - html = rewriteKnownCdnScriptSources(html).html; - html = preparePublicHtmlAssetDelivery(html, INTERNAL_AGENT_SECRET); - const embed = isPlazaEmbedRequest(req.query); - if (embed) { - html = preparePublicationHtmlForEmbed(html); - allowPlazaEmbedFrame(res); - res.set('Content-Security-Policy', publishedPageCsp(html, { embed })); - } - const context = buildPublishedHtmlViewContext({ - origin: resolveRequestOrigin(req), - requestPath: req.originalUrl || req.url || '', - filePath, - thumbnailPngPathForSvg, - }); - const parsedPublishPath = parseMindSpacePublishFilePath(filePath, __dirname); - const pageOwner = parsedPublishPath?.userId && userAuth - ? await userAuth.getUserById(parsedPublishPath.userId).catch(() => null) - : null; - let pageDataContext = null; - if (mindSpacePages) { - if (parsedPublishPath?.userId && parsedPublishPath.relativePath) { - const page = await mindSpacePages - .findPageByRelativePath(parsedPublishPath.userId, parsedPublishPath.relativePath) - .catch(() => null); - if (page?.id) { - pageDataContext = { - pageId: page.id, - accessMode: page.publicationAccessMode ?? null, - }; - } - } - } - html = injectMindSpaceAnalytics(html, { - ownerId: parsedPublishPath?.userId ?? '', - ownerSegment: resolveAnalyticsOwnerSegment(pageOwner ?? {}), - ownerLabel: resolveAnalyticsOwnerLabel(pageOwner ?? {}), - pageId: pageDataContext?.pageId ?? '', - publicationId: pageDataContext?.publicationId ?? pageDataContext?.publication_id ?? '', - config: mindSpaceAnalyticsConfig, - }); - const decorated = decorateMindSpacePublishedHtml({ - html, - embed, - isOwner, - pageDataContext, - context, - htmlFilePath: filePath, - userAgent: req.get('user-agent') || '', - preparePublicationHtmlForEmbed, - injectOgTags, - injectWechatShareBridge, - injectPublicFileShareButton, - publishedPageCsp, - isWechatUserAgent, - }); - html = decorated.html; - if (decorated.allowEmbedFrame) { - allowPlazaEmbedFrame(res); - } - res.set('Content-Security-Policy', decorated.csp); - res.set('Content-Type', 'text/html; charset=utf-8'); - // REGRESSION GUARD: mindspace-public-owner-vs-visitor — this HTML now varies by viewer - // (owner sees the Plaza entry, visitors don't), so it must never be cached/shared across - // sessions. There is no CDN/proxy_cache in front of this route today; keep it that way. - res.set('Cache-Control', 'private, no-store'); - res.send(html); -} - -async function serveUserPublishFile(req, res, next) { - const result = await resolveMindSpacePublicRequest({ - h5Root: __dirname, - requestPath: req.path, - resolveUsernameToUserId: async (username) => { - if (!authPool) return null; - const [rows] = await authPool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [username]); - return rows[0]?.id ? String(rows[0].id) : null; - }, - resolveClosestHtmlRelativePath, - ensureThumbnail: async (publishDir, relativePath) => { - await ensureWorkspaceHtmlThumbnail(publishDir, relativePath).catch(() => {}); - }, - logger: console, +const sendPublishedPage = + createPortalPublishedPageDelivery({ + registerLongImageArtifact: + registerPublishedLongImageArtifactForConversation, }); - if (result.action === 'redirect') { - res.redirect(result.status ?? 301, result.location); - return; - } - - if (result.action === 'forbidden') { - res.status(403).json({ message: '禁止访问' }); - return; - } - - if (result.action === 'not_found') { - if (result.reason === 'missing_owner_dir') { - res.status(404).json({ message: '用户不存在' }); - return; - } - if (result.reason === 'missing_directory_index') { - res.status(404).json({ message: '目录中没有 index.html' }); - return; - } - res.status(404).json({ message: '文件不存在' }); - return; - } - - // On-demand cover: rasterize .thumbnail.svg → .thumbnail.png the first time a - // forwarded link's og:image is fetched (and refresh it when the SVG changes). - const resolvedPath = result.filePath; - if (authPool && result.ownerKey && /\.html$/i.test(resolvedPath)) { - const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: result.ownerKey }); - const relativePath = path.relative(publishDir, resolvedPath).replace(/\\/g, '/'); - const contract = await getPageDeliveryContract({ - pool: authPool, - userId: result.ownerKey, - relativePath, - }).catch(() => null); - if (contract && contract.status !== 'ready') { - return res.status(409).type('text/plain; charset=utf-8').send('页面已生成,正在完成发布验证,请稍后重试。'); - } - } - if (/\.thumbnail\.png$/i.test(resolvedPath)) { - const svgSibling = resolvedPath.replace(/\.png$/i, '.svg'); - if (fs.existsSync(svgSibling)) { - const pngPath = ensureThumbnailPng(svgSibling); - if (pngPath && fs.existsSync(pngPath)) { - res.set('Cache-Control', 'public, max-age=300'); - res.sendFile(pngPath); - return; - } - } - } - - // REGRESSION GUARD: mindspace-public-owner-vs-visitor — this workspace URL is the one the - // agent hands back in chat and is reachable by anyone (no login required). Only the logged-in - // author (viewer.id === ownerKey) may see the "发布 Plaza" entry; everyone else is a visitor. - const viewer = req.userSession && userAuth - ? await userAuth.getMe(req.userToken).catch(() => null) - : null; - const isOwner = Boolean( - viewer?.id && result.ownerKey && String(viewer.id).toLowerCase() === String(result.ownerKey).toLowerCase(), - ); - - await sendPublishFile(req, res, resolvedPath, { isOwner }); -} - -app.use('/temp', (req, res) => { - res.redirect(301, `/${PUBLISH_ROOT_DIR}${req.url}`); +attachPortalPublicationRoutes({ + app, + urlencodedBody: express.urlencoded({ + extended: false, + limit: '2kb', + }), + userAuthReady, + h5Root: H5_ROOT, + getAuthPool: () => authPool, + getMindSpacePages: () => mindSpacePages, + getMindSpacePublications: () => + mindSpacePublications, + getUserAuth: () => userAuth, + sendPublishedPage, }); -// Serve user public pages: /user//path/to/file -// These are public-facing pages (like travel guides) stored in user directories -// Accessed via URL like https://goo.tkmind.cn/user/john/vietnam-guide/ -app.use('/user', async (req, res, next) => { - await userAuthReady; - // Path format: /user// - const parts = req.path.split('/').filter(Boolean); - if (parts.length < 1) return next(); - - const [username, ...rest] = parts; - const targetDir = path.join(USERS_ROOT, username); - - // Security: ensure resolved path is within the user's directory - const filePath = path.join(targetDir, ...rest); - const resolvedRoot = path.resolve(targetDir); - const resolvedPath = path.resolve(filePath); - - if (!resolvedPath.startsWith(resolvedRoot + path.sep) && resolvedPath !== resolvedRoot) { - return res.status(403).json({ message: '禁止访问' }); - } - - // Check if the user directory exists - if (!fs.existsSync(targetDir)) { - return res.status(404).json({ message: '用户不存在' }); - } - - if (!fs.existsSync(resolvedPath)) { - return res.status(404).json({ message: '文件不存在' }); - } - - // If it's a directory, serve index.html - if (fs.statSync(resolvedPath).isDirectory()) { - const indexPath = path.join(resolvedPath, 'index.html'); - if (fs.existsSync(indexPath)) { - return res.sendFile(indexPath); - } - return res.status(404).json({ message: '目录中没有 index.html' }); - } - - res.sendFile(resolvedPath, (err) => { - if (err) res.status(404).json({ message: '文件不存在' }); - }); -}); - -app.get(`/${PUBLISH_ROOT_DIR}/wiki/*`, (_req, res) => { - res.sendFile(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki', 'index.html')); -}); - -app.get('/temp/wiki/*', (req, res) => { - res.redirect(301, `/${PUBLISH_ROOT_DIR}/wiki${req.url.slice('/temp/wiki'.length)}`); -}); - -app.use( - '/plaza-covers', - express.static(path.join(__dirname, 'public/plaza-covers'), { maxAge: '7d' }), -); -app.use('/brand', express.static(path.join(__dirname, 'public/brand'), { maxAge: '7d' })); -app.use('/assets', express.static(path.join(__dirname, 'public/assets'), { maxAge: '1d' })); - -app.get('/dev/wechat-share-preview', async (req, res) => { - try { - const rawTarget = String(req.query.url ?? req.query.path ?? '').trim(); - if (!rawTarget) { - return res.status(400).type('text/plain; charset=utf-8').send('缺少 url 参数,例如 ?url=/MindSpace/john/public/demo.html'); - } - const origin = resolveRequestOrigin(req) || `http://${HOST}:${PORT}`; - const targetUrl = rawTarget.startsWith('http') ? new URL(rawTarget) : new URL(rawTarget.startsWith('/') ? rawTarget : `/${rawTarget}`, origin); - const upstream = await fetch(targetUrl.toString(), { - headers: { - 'user-agent': req.get('user-agent') || 'tkmind-wechat-share-preview', - accept: 'text/html,*/*', - }, - redirect: 'follow', - }); - if (!upstream.ok) { - return res.status(upstream.status).type('text/plain; charset=utf-8').send(`页面请求失败:HTTP ${upstream.status}`); - } - let html = await upstream.text(); - const pageUrl = targetUrl.toString().split('#')[0]; - const pageDirUrl = `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}`; - html = injectOgTags(html, { origin: targetUrl.origin, pageUrl, pageDirUrl }); - const preview = extractSharePreviewMeta(html, { - origin: targetUrl.origin, - pageUrl, - pageDirUrl, - }); - res.set('Content-Type', 'text/html; charset=utf-8'); - res.set('Cache-Control', 'no-store'); - return res.send( - renderWechatSharePreviewHtml(preview, { - note: '此预览读取页面最终 HTML 中的 Open Graph 标签,可用来对照微信里粘贴链接后的卡片效果。', - }), - ); - } catch (err) { - return res.status(500).type('text/plain; charset=utf-8').send(String(err?.message ?? err)); - } -}); -app.use('/dev', express.static(path.join(__dirname, 'public/dev'), { maxAge: 0 })); - -app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => { - const fileName = path.basename(req.path); - const filePath = path.join(__dirname, 'public', fileName); - if (!fs.existsSync(filePath)) return res.status(404).end(); - res.type('text/plain').sendFile(filePath); -}); - -app.use('/auth', (_req, res) => { - res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev)' }); -}); -app.use('/admin-api', (_req, res) => { - res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev)' }); -}); - -app.use(express.static(path.join(__dirname, 'dist'), { index: 'index.html' })); -app.get('*', (_req, res) => { - res.sendFile(path.join(__dirname, 'dist', 'index.html')); +attachPortalStaticDeliveryRoutes({ + app, + h5Root: __dirname, + host: HOST, + port: PORT, + publishRootDir: PUBLISH_ROOT_DIR, + usersRoot: USERS_ROOT, + userAuthReady, + resolveRequestOrigin, }); userAuthReady.then((enabled) => { diff --git a/server/portal-access-policy.mjs b/server/portal-access-policy.mjs new file mode 100644 index 0000000..95afc70 --- /dev/null +++ b/server/portal-access-policy.mjs @@ -0,0 +1,413 @@ +export const PORTAL_ACCESS_CLASS = Object.freeze({ + PUBLIC: 'public', + OPTIONAL_USER: 'optional-user', + AUTHENTICATED: 'authenticated', + INTERNAL: 'internal', + ROUTE_AUTHORIZED: 'route-authorized', +}); + +export const PORTAL_ACCESS_POLICY_MODE = Object.freeze({ + OFF: 'off', + SHADOW: 'shadow', +}); + +export const PORTAL_ACCESS_ENFORCEMENT_GROUPS = Object.freeze([ + 'legacy-page-data', + 'page-data-public', + 'plaza-optional-user', +]); + +const DEFAULT_SHADOW_LOG_INTERVAL_MS = 60_000; +const MIN_SHADOW_LOG_INTERVAL_MS = 1_000; +const MAX_SHADOW_LOG_INTERVAL_MS = 60 * 60_000; + +function freezeRule(rule) { + return Object.freeze({ + ...rule, + paths: rule.paths ? Object.freeze([...rule.paths]) : undefined, + methods: rule.methods ? Object.freeze([...rule.methods]) : undefined, + }); +} + +export const PORTAL_STATIC_ACCESS_RULES = Object.freeze([ + freezeRule({ + id: 'runtime-status', + accessClass: PORTAL_ACCESS_CLASS.PUBLIC, + legacyDirectBypass: true, + paths: ['/status', '/runtime/status'], + }), + freezeRule({ + id: 'blocked-words', + accessClass: PORTAL_ACCESS_CLASS.PUBLIC, + legacyDirectBypass: true, + paths: ['/config/blocked-words'], + }), + freezeRule({ + id: 'internal-agent', + accessClass: PORTAL_ACCESS_CLASS.INTERNAL, + legacyDirectBypass: true, + pathPrefix: '/internal/agent/', + }), + freezeRule({ + id: 'mindspace-agent-callback', + accessClass: PORTAL_ACCESS_CLASS.INTERNAL, + legacyDirectBypass: true, + paths: [ + '/agent/mindspace_page_patch', + '/agent/mindspace_asset_delete', + '/agent/mindspace_asset_download', + '/agent/mindspace_image_generate', + ], + }), + freezeRule({ + id: 'image-make-runtime-config', + accessClass: PORTAL_ACCESS_CLASS.INTERNAL, + legacyDirectBypass: true, + paths: ['/internal/image-make/runtime-config'], + }), + freezeRule({ + id: 'mindspace-asset-download', + accessClass: PORTAL_ACCESS_CLASS.ROUTE_AUTHORIZED, + legacyDirectBypass: true, + methods: ['GET'], + pathPattern: /^\/mindspace\/v1\/assets\/[^/]+\/download$/, + }), + freezeRule({ + id: 'plaza-events', + policyGroup: 'plaza-optional-user', + accessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER, + methods: ['POST'], + paths: ['/plaza/v1/events'], + }), + freezeRule({ + id: 'plaza-discovery', + policyGroup: 'plaza-optional-user', + accessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER, + methods: ['GET'], + paths: ['/plaza/v1/feed', '/plaza/v1/categories', '/plaza/v1/seo/sitemap'], + }), + freezeRule({ + id: 'plaza-post-detail', + policyGroup: 'plaza-optional-user', + accessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER, + methods: ['GET'], + pathPattern: /^\/plaza\/v1\/posts\/[^/]+$/, + }), + freezeRule({ + id: 'plaza-post-comments', + policyGroup: 'plaza-optional-user', + accessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER, + methods: ['GET'], + pathPattern: /^\/plaza\/v1\/posts\/[^/]+\/comments$/, + }), + freezeRule({ + id: 'plaza-user-profile', + policyGroup: 'plaza-optional-user', + accessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER, + methods: ['GET'], + pathPattern: /^\/plaza\/v1\/users\/[^/]+$/, + }), + freezeRule({ + id: 'plaza-user-posts', + policyGroup: 'plaza-optional-user', + accessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER, + methods: ['GET'], + pathPattern: /^\/plaza\/v1\/users\/[^/]+\/posts$/, + }), +]); + +function normalizeRequest({ path, method } = {}) { + return { + path: String(path ?? ''), + method: String(method ?? 'GET').trim().toUpperCase() || 'GET', + }; +} + +function ruleMatches(rule, request) { + if (rule.methods && !rule.methods.includes(request.method)) return false; + if (rule.paths?.includes(request.path)) return true; + if (rule.pathPrefix && request.path.startsWith(rule.pathPrefix)) return true; + return Boolean(rule.pathPattern?.test(request.path)); +} + +export function findPortalStaticAccessRule(requestInput) { + const request = normalizeRequest(requestInput); + return PORTAL_STATIC_ACCESS_RULES.find((rule) => ruleMatches(rule, request)) ?? null; +} + +export function resolvePortalAccessPolicyMode(env = process.env) { + const raw = String(env.MEMIND_PORTAL_ACCESS_POLICY_MODE ?? '').trim().toLowerCase(); + return raw === PORTAL_ACCESS_POLICY_MODE.SHADOW + ? PORTAL_ACCESS_POLICY_MODE.SHADOW + : PORTAL_ACCESS_POLICY_MODE.OFF; +} + +export function resolvePortalAccessShadowLogIntervalMs(env = process.env) { + const parsed = Number(env.MEMIND_PORTAL_ACCESS_SHADOW_LOG_INTERVAL_MS); + if (!Number.isFinite(parsed)) return DEFAULT_SHADOW_LOG_INTERVAL_MS; + return Math.min( + MAX_SHADOW_LOG_INTERVAL_MS, + Math.max(MIN_SHADOW_LOG_INTERVAL_MS, Math.trunc(parsed)), + ); +} + +function isExplicitlyEnabled(value) { + return String(value ?? '').trim() === '1'; +} + +export function resolvePortalAccessEnforcementConfig(env = process.env) { + const masterEnabled = isExplicitlyEnabled( + env.MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED, + ); + const killSwitch = isExplicitlyEnabled( + env.MEMIND_PORTAL_ACCESS_POLICY_KILL_SWITCH, + ); + const requestedGroups = [ + ...new Set( + String(env.MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS ?? '') + .split(',') + .map((group) => group.trim()) + .filter(Boolean), + ), + ]; + const unknownGroups = requestedGroups.filter( + (group) => !PORTAL_ACCESS_ENFORCEMENT_GROUPS.includes(group), + ); + + if (masterEnabled && unknownGroups.length > 0) { + throw new Error( + `Unknown Portal access enforcement group(s): ${unknownGroups.join(', ')}`, + ); + } + + const activeGroups = + masterEnabled && !killSwitch + ? requestedGroups.filter((group) => + PORTAL_ACCESS_ENFORCEMENT_GROUPS.includes(group)) + : []; + + return Object.freeze({ + masterEnabled, + killSwitch, + enabled: activeGroups.length > 0, + requestedGroups: Object.freeze(requestedGroups), + activeGroups: Object.freeze(activeGroups), + unknownGroups: Object.freeze(unknownGroups), + }); +} + +export function isPortalLegacyDirectGlobalAuthBypass(path, method) { + return Boolean( + findPortalStaticAccessRule({ path, method })?.legacyDirectBypass, + ); +} + +export function isPortalPlazaOptionalUserPath(path, method) { + return ( + findPortalStaticAccessRule({ path, method })?.policyGroup === + 'plaza-optional-user' + ); +} + +export function resolvePortalApiAccess( + requestInput, + { + isPageDataPublicPath = () => false, + isLegacyPageDataApiPath = () => false, + } = {}, +) { + const request = normalizeRequest(requestInput); + const staticRule = findPortalStaticAccessRule(request); + if (staticRule) { + return { + accessClass: staticRule.accessClass, + ruleId: staticRule.id, + policyGroup: staticRule.policyGroup ?? staticRule.id, + }; + } + + if (isPageDataPublicPath(request.path, request.method)) { + return { + accessClass: PORTAL_ACCESS_CLASS.ROUTE_AUTHORIZED, + ruleId: 'page-data-public', + policyGroup: 'page-data-public', + }; + } + + if (isLegacyPageDataApiPath(request.path)) { + return { + accessClass: PORTAL_ACCESS_CLASS.PUBLIC, + ruleId: 'legacy-page-data', + policyGroup: 'legacy-page-data', + }; + } + + return { + accessClass: PORTAL_ACCESS_CLASS.AUTHENTICATED, + ruleId: 'authenticated-default', + policyGroup: 'authenticated-default', + }; +} + +export function classifyPortalApiAccess(requestInput, predicates = {}) { + return resolvePortalApiAccess(requestInput, predicates).accessClass; +} + +export function portalAccessBypassesGlobalAuth(accessClass) { + return accessClass !== PORTAL_ACCESS_CLASS.AUTHENTICATED; +} + +export function resolvePortalAccessEnforcementDecision( + requestInput, + predicates, + config, +) { + const proposed = resolvePortalApiAccess(requestInput, predicates); + const activeGroups = config?.activeGroups ?? []; + const selected = + Boolean(config?.enabled) && + activeGroups.includes(proposed.policyGroup); + let reason = 'group-disabled'; + if (config?.killSwitch) reason = 'kill-switch'; + else if (!config?.masterEnabled) reason = 'master-disabled'; + else if (selected) reason = 'group-enabled'; + + return { + selected, + bypassGlobalAuth: + selected && portalAccessBypassesGlobalAuth(proposed.accessClass), + reason, + proposedAccessClass: proposed.accessClass, + proposedRuleId: proposed.ruleId, + policyGroup: proposed.policyGroup, + }; +} + +export function shouldOverridePortalLegacyGlobalAuth( + decision, + currentGlobalAuthBypass, +) { + return ( + Boolean(decision?.selected) && + Boolean(decision?.bypassGlobalAuth) && + !Boolean(currentGlobalAuthBypass) + ); +} + +export function comparePortalAccessPolicyShadow( + requestInput, + { + multiUserEnabled = false, + isPageDataPublicPath = () => false, + isLegacyPageDataApiPath = () => false, + } = {}, +) { + const request = normalizeRequest(requestInput); + const plazaOptionalUser = isPortalPlazaOptionalUserPath(request.path, request.method); + const pageDataRouteAuthorized = isPageDataPublicPath(request.path, request.method); + const legacyPageDataPublic = isLegacyPageDataApiPath(request.path); + const currentGlobalAuthBypass = + isPortalLegacyDirectGlobalAuthBypass(request.path, request.method) || + ( + Boolean(multiUserEnabled) && + (plazaOptionalUser || pageDataRouteAuthorized || legacyPageDataPublic) + ); + const proposed = resolvePortalApiAccess(request, { + isPageDataPublicPath, + isLegacyPageDataApiPath, + }); + const proposedGlobalAuthBypass = portalAccessBypassesGlobalAuth(proposed.accessClass); + + return { + path: request.path, + method: request.method, + proposedAccessClass: proposed.accessClass, + proposedRuleId: proposed.ruleId, + policyGroup: proposed.policyGroup, + currentGlobalAuthBypass, + proposedGlobalAuthBypass, + mismatch: currentGlobalAuthBypass !== proposedGlobalAuthBypass, + }; +} + +export function assessPortalAccessMigrationReadiness( + requests, + { + knownMismatchPolicyGroups = [], + ...comparisonOptions + } = {}, +) { + const knownGroups = new Set(knownMismatchPolicyGroups); + const comparisons = requests.map((request) => + comparePortalAccessPolicyShadow(request, comparisonOptions)); + const mismatches = comparisons.filter((comparison) => comparison.mismatch); + const knownMismatches = mismatches.filter((comparison) => + knownGroups.has(comparison.policyGroup)); + const unexpectedMismatches = mismatches.filter((comparison) => + !knownGroups.has(comparison.policyGroup)); + + return { + contractReady: unexpectedMismatches.length === 0, + safeForBehaviorSwitch: mismatches.length === 0, + total: comparisons.length, + aligned: comparisons.length - mismatches.length, + knownMismatches, + unexpectedMismatches, + }; +} + +export function createPortalAccessShadowReporter({ + intervalMs = DEFAULT_SHADOW_LOG_INTERVAL_MS, + now = Date.now, + emit = () => {}, +} = {}) { + const effectiveIntervalMs = Math.min( + MAX_SHADOW_LOG_INTERVAL_MS, + Math.max(MIN_SHADOW_LOG_INTERVAL_MS, Math.trunc(Number(intervalMs) || 0)), + ); + const counters = new Map(); + + function record(event) { + if (!event?.mismatch) return null; + const key = [ + event.authMode ?? 'unknown', + event.method ?? 'GET', + event.policyGroup ?? event.proposedRuleId ?? 'unknown', + ].join(':'); + const timestamp = Number(now()); + const counter = counters.get(key) ?? { + key, + totalOccurrences: 0, + suppressedSinceLast: 0, + lastEmittedAt: null, + }; + counter.totalOccurrences += 1; + + const shouldEmit = + counter.lastEmittedAt === null || + timestamp - counter.lastEmittedAt >= effectiveIntervalMs; + if (!shouldEmit) { + counter.suppressedSinceLast += 1; + counters.set(key, counter); + return null; + } + + const payload = { + ...event, + occurrences: counter.totalOccurrences, + suppressedSinceLast: counter.suppressedSinceLast, + shadowLogIntervalMs: effectiveIntervalMs, + }; + counter.lastEmittedAt = timestamp; + counter.suppressedSinceLast = 0; + counters.set(key, counter); + emit(payload); + return payload; + } + + function snapshot() { + return [...counters.values()].map((counter) => ({ ...counter })); + } + + return Object.freeze({ record, snapshot }); +} diff --git a/server/portal-access-policy.test.mjs b/server/portal-access-policy.test.mjs new file mode 100644 index 0000000..5e53f40 --- /dev/null +++ b/server/portal-access-policy.test.mjs @@ -0,0 +1,442 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isLegacyPageDataApiPath } from '../page-data-routes.mjs'; +import { isPageDataPublicPath } from '../page-data-public-service.mjs'; +import { + assessPortalAccessMigrationReadiness, + classifyPortalApiAccess, + comparePortalAccessPolicyShadow, + createPortalAccessShadowReporter, + findPortalStaticAccessRule, + isPortalLegacyDirectGlobalAuthBypass, + isPortalPlazaOptionalUserPath, + PORTAL_ACCESS_CLASS, + PORTAL_ACCESS_ENFORCEMENT_GROUPS, + PORTAL_STATIC_ACCESS_RULES, + resolvePortalAccessEnforcementConfig, + resolvePortalAccessEnforcementDecision, + resolvePortalAccessPolicyMode, + resolvePortalAccessShadowLogIntervalMs, + shouldOverridePortalLegacyGlobalAuth, +} from './portal-access-policy.mjs'; + +const pageDataPredicates = { + isPageDataPublicPath, + isLegacyPageDataApiPath, +}; + +const boundaryCases = [ + ['/status', 'GET', PORTAL_ACCESS_CLASS.PUBLIC, true], + ['/runtime/status', 'POST', PORTAL_ACCESS_CLASS.PUBLIC, true], + ['/config/blocked-words', 'GET', PORTAL_ACCESS_CLASS.PUBLIC, true], + ['/internal/agent/jobs/job-1/claim', 'POST', PORTAL_ACCESS_CLASS.INTERNAL, true], + ['/agent/mindspace_page_patch', 'POST', PORTAL_ACCESS_CLASS.INTERNAL, true], + ['/agent/mindspace_asset_delete', 'POST', PORTAL_ACCESS_CLASS.INTERNAL, true], + ['/agent/mindspace_asset_download', 'POST', PORTAL_ACCESS_CLASS.INTERNAL, true], + ['/agent/mindspace_image_generate', 'POST', PORTAL_ACCESS_CLASS.INTERNAL, true], + ['/internal/image-make/runtime-config', 'GET', PORTAL_ACCESS_CLASS.INTERNAL, true], + ['/mindspace/v1/assets/asset-1/download', 'GET', PORTAL_ACCESS_CLASS.ROUTE_AUTHORIZED, true], + ['/mindspace/v1/assets/asset-1/download', 'POST', PORTAL_ACCESS_CLASS.AUTHENTICATED, false], + ['/plaza/v1/categories', 'GET', PORTAL_ACCESS_CLASS.OPTIONAL_USER, false], + ['/plaza/v1/feed', 'get', PORTAL_ACCESS_CLASS.OPTIONAL_USER, false], + ['/plaza/v1/posts/post-1', 'GET', PORTAL_ACCESS_CLASS.OPTIONAL_USER, false], + ['/plaza/v1/posts/post-1/comments', 'GET', PORTAL_ACCESS_CLASS.OPTIONAL_USER, false], + ['/plaza/v1/users/alice', 'GET', PORTAL_ACCESS_CLASS.OPTIONAL_USER, false], + ['/plaza/v1/users/alice/posts', 'GET', PORTAL_ACCESS_CLASS.OPTIONAL_USER, false], + ['/plaza/v1/events', 'POST', PORTAL_ACCESS_CLASS.OPTIONAL_USER, false], + ['/plaza/v1/posts/post-1/reports', 'POST', PORTAL_ACCESS_CLASS.AUTHENTICATED, false], + [ + '/public/pages/page-1/data/signups/rows', + 'POST', + PORTAL_ACCESS_CLASS.ROUTE_AUTHORIZED, + false, + ], + ['/public/pages/page-1/data/signups', 'GET', PORTAL_ACCESS_CLASS.ROUTE_AUTHORIZED, false], + ['/page-data/signups', 'GET', PORTAL_ACCESS_CLASS.PUBLIC, false], + ['/mindspace/v1/pages', 'GET', PORTAL_ACCESS_CLASS.AUTHENTICATED, false], +]; + +test('static access registry has stable unique rule ids', () => { + const ids = PORTAL_STATIC_ACCESS_RULES.map((rule) => rule.id); + assert.equal(new Set(ids).size, ids.length); + assert.ok(ids.length >= 10); + for (const rule of PORTAL_STATIC_ACCESS_RULES) { + assert.ok(Object.values(PORTAL_ACCESS_CLASS).includes(rule.accessClass), rule.id); + } +}); + +test('portal access policy classifies the existing route boundary', () => { + for (const [path, method, expected] of boundaryCases) { + assert.equal( + classifyPortalApiAccess({ path, method }, pageDataPredicates), + expected, + `${method} ${path}`, + ); + } +}); + +test('legacy direct bypass derives from the same access registry', () => { + for (const [path, method, _expectedClass, expectedDirectBypass] of boundaryCases) { + assert.equal( + isPortalLegacyDirectGlobalAuthBypass(path, method), + expectedDirectBypass, + `${method} ${path}`, + ); + } + assert.equal(findPortalStaticAccessRule({ path: '/mindspace/v1/pages', method: 'GET' }), null); +}); + +test('Plaza optional-user matcher excludes authenticated write routes', () => { + assert.equal(isPortalPlazaOptionalUserPath('/plaza/v1/seo/sitemap', 'GET'), true); + assert.equal(isPortalPlazaOptionalUserPath('/plaza/v1/events', 'POST'), true); + assert.equal(isPortalPlazaOptionalUserPath('/plaza/v1/posts/post-1', 'PATCH'), false); + assert.equal(isPortalPlazaOptionalUserPath('/plaza/v1/posts/post-1/reports', 'POST'), false); + assert.equal(isPortalPlazaOptionalUserPath('/mindspace/v1/pages', 'GET'), false); +}); + +test('portal access policy flags are safely bounded', () => { + assert.equal(resolvePortalAccessPolicyMode({}), 'off'); + assert.equal( + resolvePortalAccessPolicyMode({ MEMIND_PORTAL_ACCESS_POLICY_MODE: ' SHADOW ' }), + 'shadow', + ); + assert.equal(resolvePortalAccessPolicyMode({ MEMIND_PORTAL_ACCESS_POLICY_MODE: 'on' }), 'off'); + assert.equal(resolvePortalAccessPolicyMode({ MEMIND_PORTAL_ACCESS_POLICY_MODE: 'invalid' }), 'off'); + assert.equal(resolvePortalAccessShadowLogIntervalMs({}), 60_000); + assert.equal( + resolvePortalAccessShadowLogIntervalMs({ + MEMIND_PORTAL_ACCESS_SHADOW_LOG_INTERVAL_MS: '1', + }), + 1_000, + ); + assert.equal( + resolvePortalAccessShadowLogIntervalMs({ + MEMIND_PORTAL_ACCESS_SHADOW_LOG_INTERVAL_MS: '9999999', + }), + 3_600_000, + ); +}); + +test('access enforcement requires the master switch and an exact group', () => { + const defaultConfig = resolvePortalAccessEnforcementConfig({}); + assert.equal(defaultConfig.enabled, false); + assert.deepEqual(defaultConfig.activeGroups, []); + + const groupOnly = resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: 'legacy-page-data', + }); + assert.equal(groupOnly.enabled, false); + assert.deepEqual(groupOnly.requestedGroups, ['legacy-page-data']); + + const enabled = resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1', + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: + ' legacy-page-data,legacy-page-data ', + }); + assert.equal(enabled.enabled, true); + assert.deepEqual(enabled.activeGroups, ['legacy-page-data']); +}); + +test('access enforcement rejects all and unknown groups when master is enabled', () => { + assert.deepEqual(PORTAL_ACCESS_ENFORCEMENT_GROUPS, [ + 'legacy-page-data', + 'page-data-public', + 'plaza-optional-user', + ]); + assert.throws( + () => + resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1', + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: 'all', + }), + /Unknown Portal access enforcement group\(s\): all/, + ); + assert.throws( + () => + resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1', + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: + 'legacy-page-data,typo-group', + }), + /typo-group/, + ); +}); + +test('kill switch overrides every requested enforcement group', () => { + const config = resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1', + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: + PORTAL_ACCESS_ENFORCEMENT_GROUPS.join(','), + MEMIND_PORTAL_ACCESS_POLICY_KILL_SWITCH: '1', + }); + assert.equal(config.masterEnabled, true); + assert.equal(config.killSwitch, true); + assert.equal(config.enabled, false); + assert.deepEqual(config.activeGroups, []); +}); + +test('single-group enforcement bypasses only the selected policy group', () => { + const config = resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1', + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: 'legacy-page-data', + }); + const legacyDecision = resolvePortalAccessEnforcementDecision( + { path: '/page-data/signups', method: 'GET' }, + pageDataPredicates, + config, + ); + assert.deepEqual(legacyDecision, { + selected: true, + bypassGlobalAuth: true, + reason: 'group-enabled', + proposedAccessClass: PORTAL_ACCESS_CLASS.PUBLIC, + proposedRuleId: 'legacy-page-data', + policyGroup: 'legacy-page-data', + }); + + const publicPageDataDecision = resolvePortalAccessEnforcementDecision( + { path: '/public/pages/page-1/data/signups', method: 'GET' }, + pageDataPredicates, + config, + ); + assert.equal(publicPageDataDecision.selected, false); + assert.equal(publicPageDataDecision.bypassGlobalAuth, false); + assert.equal(publicPageDataDecision.reason, 'group-disabled'); + + const privateDecision = resolvePortalAccessEnforcementDecision( + { path: '/mindspace/v1/pages', method: 'GET' }, + pageDataPredicates, + config, + ); + assert.equal(privateDecision.selected, false); + assert.equal(privateDecision.bypassGlobalAuth, false); + assert.equal(privateDecision.policyGroup, 'authenticated-default'); +}); + +test('enforcement preserves legacy optional-user hydration when already bypassed', () => { + const config = resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1', + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: 'page-data-public', + }); + const decision = resolvePortalAccessEnforcementDecision( + { path: '/public/pages/page-1/data/signups', method: 'GET' }, + pageDataPredicates, + config, + ); + + assert.equal(decision.selected, true); + assert.equal(decision.bypassGlobalAuth, true); + assert.equal( + shouldOverridePortalLegacyGlobalAuth(decision, false), + true, + 'no-auth and legacy modes need the new bypass', + ); + assert.equal( + shouldOverridePortalLegacyGlobalAuth(decision, true), + false, + 'multi-user mode must continue through legacy hydration', + ); +}); + +test('enforcement decision reports kill switch and master-off reasons', () => { + const request = { path: '/page-data/signups', method: 'GET' }; + const groupOnly = resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: 'legacy-page-data', + }); + assert.equal( + resolvePortalAccessEnforcementDecision(request, pageDataPredicates, groupOnly).reason, + 'master-disabled', + ); + + const killed = resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1', + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: 'legacy-page-data', + MEMIND_PORTAL_ACCESS_POLICY_KILL_SWITCH: '1', + }); + assert.equal( + resolvePortalAccessEnforcementDecision(request, pageDataPredicates, killed).reason, + 'kill-switch', + ); +}); + +test('shadow comparison matches current direct bypass and authenticated routes', () => { + const cases = [ + { path: '/status', method: 'GET', multiUserEnabled: false }, + { path: '/internal/agent/jobs/job-1/claim', method: 'POST', multiUserEnabled: false }, + { + path: '/mindspace/v1/assets/asset-1/download', + method: 'GET', + multiUserEnabled: false, + }, + { path: '/plaza/v1/categories', method: 'GET', multiUserEnabled: true }, + { path: '/mindspace/v1/pages', method: 'GET', multiUserEnabled: false }, + { path: '/mindspace/v1/pages', method: 'GET', multiUserEnabled: true }, + ]; + + for (const request of cases) { + const comparison = comparePortalAccessPolicyShadow(request, { + ...pageDataPredicates, + multiUserEnabled: request.multiUserEnabled, + }); + assert.equal(comparison.mismatch, false, `${request.method} ${request.path}`); + } +}); + +test('shadow comparison exposes public-route coupling to the multi-user auth mode', () => { + const cases = [ + ['/plaza/v1/categories', 'GET', PORTAL_ACCESS_CLASS.OPTIONAL_USER, 'plaza-optional-user'], + [ + '/public/pages/page-1/data/signups', + 'GET', + PORTAL_ACCESS_CLASS.ROUTE_AUTHORIZED, + 'page-data-public', + ], + ['/page-data/signups', 'GET', PORTAL_ACCESS_CLASS.PUBLIC, 'legacy-page-data'], + ]; + + for (const [path, method, expectedClass, expectedGroup] of cases) { + const comparison = comparePortalAccessPolicyShadow( + { path, method }, + { + ...pageDataPredicates, + multiUserEnabled: false, + }, + ); + assert.equal(comparison.proposedAccessClass, expectedClass); + assert.equal(comparison.policyGroup, expectedGroup); + assert.equal(comparison.currentGlobalAuthBypass, false); + assert.equal(comparison.proposedGlobalAuthBypass, true); + assert.equal(comparison.mismatch, true); + } +}); + +test('shadow comparison stays aligned for public routes in multi-user mode', () => { + const comparison = comparePortalAccessPolicyShadow( + { path: '/plaza/v1/categories', method: 'GET' }, + { + ...pageDataPredicates, + multiUserEnabled: true, + }, + ); + + assert.deepEqual(comparison, { + path: '/plaza/v1/categories', + method: 'GET', + proposedAccessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER, + proposedRuleId: 'plaza-discovery', + policyGroup: 'plaza-optional-user', + currentGlobalAuthBypass: true, + proposedGlobalAuthBypass: true, + mismatch: false, + }); +}); + +test('migration readiness separates known differences from unexpected drift', () => { + const requests = [ + { path: '/plaza/v1/categories', method: 'GET' }, + { path: '/public/pages/page-1/data/signups', method: 'GET' }, + { path: '/page-data/signups', method: 'GET' }, + { path: '/mindspace/v1/pages', method: 'GET' }, + ]; + const knownMismatchPolicyGroups = [ + 'plaza-optional-user', + 'page-data-public', + 'legacy-page-data', + ]; + const report = assessPortalAccessMigrationReadiness(requests, { + ...pageDataPredicates, + multiUserEnabled: false, + knownMismatchPolicyGroups, + }); + + assert.equal(report.contractReady, true); + assert.equal(report.safeForBehaviorSwitch, false); + assert.equal(report.total, 4); + assert.equal(report.aligned, 1); + assert.equal(report.knownMismatches.length, 3); + assert.equal(report.unexpectedMismatches.length, 0); + + const strictReport = assessPortalAccessMigrationReadiness(requests, { + ...pageDataPredicates, + multiUserEnabled: false, + knownMismatchPolicyGroups: knownMismatchPolicyGroups.slice(0, 2), + }); + assert.equal(strictReport.contractReady, false); + assert.equal(strictReport.unexpectedMismatches[0].policyGroup, 'legacy-page-data'); +}); + +test('multi-user route contract is ready and aligned', () => { + const report = assessPortalAccessMigrationReadiness( + boundaryCases.map(([path, method]) => ({ path, method })), + { + ...pageDataPredicates, + multiUserEnabled: true, + }, + ); + assert.equal(report.contractReady, true); + assert.equal(report.safeForBehaviorSwitch, true); + assert.equal(report.aligned, boundaryCases.length); +}); + +test('shadow reporter groups dynamic routes, suppresses repeats, and preserves counts', () => { + let currentTime = 10_000; + const emitted = []; + const reporter = createPortalAccessShadowReporter({ + intervalMs: 1_000, + now: () => currentTime, + emit: (event) => emitted.push(event), + }); + const mismatch = { + authMode: 'none', + method: 'GET', + proposedAccessClass: PORTAL_ACCESS_CLASS.OPTIONAL_USER, + policyGroup: 'plaza-optional-user', + currentGlobalAuthBypass: false, + proposedGlobalAuthBypass: true, + mismatch: true, + }; + + reporter.record({ + ...mismatch, + requestId: 'req-1', + path: '/plaza/v1/posts/post-1', + proposedRuleId: 'plaza-post-detail', + }); + currentTime += 500; + reporter.record({ + ...mismatch, + requestId: 'req-2', + path: '/plaza/v1/users/alice', + proposedRuleId: 'plaza-user-profile', + }); + + assert.equal(emitted.length, 1); + assert.deepEqual(reporter.snapshot(), [{ + key: 'none:GET:plaza-optional-user', + totalOccurrences: 2, + suppressedSinceLast: 1, + lastEmittedAt: 10_000, + }]); + + currentTime += 501; + const next = reporter.record({ + ...mismatch, + requestId: 'req-3', + path: '/plaza/v1/feed', + proposedRuleId: 'plaza-discovery', + }); + assert.equal(emitted.length, 2); + assert.equal(next.occurrences, 3); + assert.equal(next.suppressedSinceLast, 1); + assert.equal(next.requestId, 'req-3'); +}); + +test('shadow reporter ignores aligned requests', () => { + const emitted = []; + const reporter = createPortalAccessShadowReporter({ + emit: (event) => emitted.push(event), + }); + assert.equal(reporter.record({ mismatch: false }), null); + assert.deepEqual(reporter.snapshot(), []); + assert.deepEqual(emitted, []); +}); diff --git a/server/portal-account-feedback-routes.mjs b/server/portal-account-feedback-routes.mjs new file mode 100644 index 0000000..7943e8b --- /dev/null +++ b/server/portal-account-feedback-routes.mjs @@ -0,0 +1,304 @@ +export function attachPortalAccountFeedbackRoutes({ + app, + jsonBody, + userAuthReady = Promise.resolve(), + getUserAuth = () => null, + getLegacyAuth = () => null, + getSubscriptionService = () => null, + getFeedbackService = () => null, + userToken, + legacySessionToken, + clearUserLoginCookies, + resolveSkillRuntimeForClient = async () => null, + resolveAgentCodeRunForClient = async () => null, + logger = console, +} = {}) { + if ( + !app || + typeof jsonBody !== 'function' || + typeof userToken !== 'function' || + typeof legacySessionToken !== 'function' || + typeof clearUserLoginCookies !== 'function' + ) { + throw new Error( + 'attachPortalAccountFeedbackRoutes requires route dependencies', + ); + } + + app.get('/auth/me', async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + if (!userAuth) { + return res.status(503).json({ + message: '未启用用户系统', + }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const subscriptionService = + getSubscriptionService(); + const [ + paths, + capabilityState, + subscription, + skillRuntime, + agentCodeRun, + ] = await Promise.all([ + userAuth.listPathGrants(me.id), + userAuth.resolveUserCapabilities( + await userAuth.getUserById(me.id), + ), + subscriptionService + ? subscriptionService.getActiveSubscription( + me.id, + ) + : null, + resolveSkillRuntimeForClient(), + resolveAgentCodeRunForClient(me.id), + ]); + return res.json({ + user: { + ...me, + subscription, + }, + paths, + capabilities: capabilityState.capabilities, + grantedSkills: + capabilityState.grantedSkills ?? [], + unrestricted: capabilityState.unrestricted, + skillRuntime, + agentCodeRun, + }); + }); + + app.post('/auth/logout', async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const legacyAuth = getLegacyAuth(); + if (userAuth) { + await userAuth.revoke(userToken(req)); + } + if (legacyAuth) { + legacyAuth.revoke(legacySessionToken(req)); + } + if (userAuth || legacyAuth) { + clearUserLoginCookies(res, req); + } + res.status(204).end(); + }); + + app.get('/auth/usage', async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + if (!userAuth) { + return res.status(503).json({ + message: '未启用用户系统', + }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const records = await userAuth.listUsageRecords({ + userId: me.id, + limit: 30, + }); + res.json({ records }); + }); + + app.post( + '/auth/feedback', + jsonBody, + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const feedbackService = getFeedbackService(); + if (!userAuth || !feedbackService) { + return res.status(503).json({ + message: '反馈服务未启用', + }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + try { + const result = await feedbackService.submit( + me.id, + { + type: req.body?.type, + title: req.body?.title, + description: req.body?.description, + contact: req.body?.contact, + images: req.body?.images, + context: req.body?.context, + }, + ); + res.status(201).json({ + feedback: result, + }); + } catch (error) { + const code = + error && + typeof error === 'object' && + 'code' in error + ? String(error.code) + : ''; + if (code === 'invalid_input') { + return res.status(400).json({ + message: + error instanceof Error + ? error.message + : '提交内容无效', + }); + } + logger.warn( + 'Submit feedback failed:', + error instanceof Error + ? error.message + : error, + ); + return res.status(500).json({ + message: '反馈提交失败,请稍后重试', + }); + } + }, + ); + + app.get('/auth/feedback', async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const feedbackService = getFeedbackService(); + if (!userAuth || !feedbackService) { + return res.status(503).json({ + message: '反馈服务未启用', + }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const limit = Math.min( + Math.max(Number(req.query?.limit) || 20, 1), + 50, + ); + const items = await feedbackService.listForUser( + me.id, + { limit }, + ); + res.json({ items }); + }); + + app.get( + '/auth/feedback/board', + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const feedbackService = getFeedbackService(); + if (!userAuth || !feedbackService) { + return res.status(503).json({ + message: '反馈服务未启用', + }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const page = Math.max( + Number(req.query?.page) || 1, + 1, + ); + const limit = Math.min( + Math.max( + Number(req.query?.limit) || 10, + 1, + ), + 10, + ); + try { + const result = + await feedbackService.listAll({ + page, + limit, + }); + res.json(result); + } catch (error) { + logger.warn( + 'List feedback board failed:', + error instanceof Error + ? error.message + : error, + ); + return res.status(500).json({ + message: '反馈列表加载失败', + }); + } + }, + ); + + app.get( + '/auth/feedback/:feedbackId', + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const feedbackService = getFeedbackService(); + if (!userAuth || !feedbackService) { + return res.status(503).json({ + message: '反馈服务未启用', + }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + try { + const item = await feedbackService.getById( + req.params.feedbackId, + me.id, + ); + res.json({ + item, + isMine: item.userId === me.id, + }); + } catch (error) { + const code = + error && + typeof error === 'object' && + 'code' in error + ? String(error.code) + : ''; + if (code === 'feedback_not_found') { + return res.status(404).json({ + message: + error instanceof Error + ? error.message + : '反馈不存在', + }); + } + logger.warn( + 'Get feedback detail failed:', + error instanceof Error + ? error.message + : error, + ); + return res.status(500).json({ + message: '反馈详情加载失败', + }); + } + }, + ); +} diff --git a/server/portal-account-feedback-routes.test.mjs b/server/portal-account-feedback-routes.test.mjs new file mode 100644 index 0000000..0a915fb --- /dev/null +++ b/server/portal-account-feedback-routes.test.mjs @@ -0,0 +1,534 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + attachPortalAccountFeedbackRoutes, +} from './portal-account-feedback-routes.mjs'; + +function createResponse() { + return { + statusCode: 200, + body: undefined, + ended: false, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + end() { + this.ended = true; + return this; + }, + }; +} + +function createSetup(overrides = {}) { + const routes = []; + const calls = []; + let userAuth = { + async getMe(token) { + calls.push(['get-me', token]); + return { + id: 'user-1', + username: 'john', + }; + }, + async listPathGrants(userId) { + calls.push(['paths', userId]); + return ['/workspace']; + }, + async getUserById(userId) { + calls.push(['get-user', userId]); + return { id: userId }; + }, + async resolveUserCapabilities(row) { + calls.push(['capabilities', row]); + return { + capabilities: ['chat'], + grantedSkills: ['web'], + unrestricted: false, + }; + }, + async revoke(token) { + calls.push(['user-revoke', token]); + }, + async listUsageRecords(options) { + calls.push(['usage', options]); + return [{ id: 'usage-1' }]; + }, + }; + let legacyAuth = { + revoke(token) { + calls.push(['legacy-revoke', token]); + }, + }; + let subscriptionService = { + async getActiveSubscription(userId) { + calls.push(['subscription', userId]); + return { + planType: 'pro', + }; + }, + }; + let feedbackService = { + async submit(userId, payload) { + calls.push([ + 'feedback-submit', + userId, + payload, + ]); + return { + id: 'feedback-1', + ...payload, + }; + }, + async listForUser(userId, options) { + calls.push([ + 'feedback-list-user', + userId, + options, + ]); + return [{ id: 'feedback-1' }]; + }, + async listAll(options) { + calls.push(['feedback-list-all', options]); + return { + items: [{ id: 'feedback-1' }], + page: options.page, + limit: options.limit, + }; + }, + async getById(feedbackId, userId) { + calls.push([ + 'feedback-detail', + feedbackId, + userId, + ]); + return { + id: feedbackId, + userId, + }; + }, + }; + const jsonBody = (_req, _res, next) => next(); + const app = { + get(path, ...handlers) { + routes.push({ + method: 'get', + path, + handlers, + }); + }, + post(path, ...handlers) { + routes.push({ + method: 'post', + path, + handlers, + }); + }, + }; + const options = { + app, + jsonBody, + userAuthReady: Promise.resolve(), + getUserAuth: () => userAuth, + getLegacyAuth: () => legacyAuth, + getSubscriptionService: () => + subscriptionService, + getFeedbackService: () => feedbackService, + userToken: () => 'request-user-token', + legacySessionToken: () => + 'request-legacy-token', + clearUserLoginCookies() { + calls.push(['clear-cookies']); + }, + async resolveSkillRuntimeForClient() { + calls.push(['skill-runtime']); + return { + enabled: true, + }; + }, + async resolveAgentCodeRunForClient(userId) { + calls.push(['agent-code-run', userId]); + return { + enabled: true, + userId, + }; + }, + logger: { + warn(...args) { + calls.push(['warn', ...args]); + }, + }, + ...overrides, + }; + attachPortalAccountFeedbackRoutes(options); + + return { + routes, + calls, + jsonBody, + setUserAuth(value) { + userAuth = value; + }, + setLegacyAuth(value) { + legacyAuth = value; + }, + setSubscriptionService(value) { + subscriptionService = value; + }, + setFeedbackService(value) { + feedbackService = value; + }, + route(method, path) { + return routes.find( + (route) => + route.method === method && + route.path === path, + ); + }, + }; +} + +async function invoke( + setup, + method, + path, + req = {}, +) { + const route = setup.route(method, path); + assert.ok( + route, + `${method.toUpperCase()} ${path}`, + ); + const res = createResponse(); + await route.handlers.at(-1)( + { + body: {}, + query: {}, + params: {}, + ip: '127.0.0.1', + ...req, + }, + res, + ); + return res; +} + +test('registers the account and feedback route inventory in order', () => { + const setup = createSetup(); + assert.deepEqual( + setup.routes.map(({ method, path }) => [ + method, + path, + ]), + [ + ['get', '/auth/me'], + ['post', '/auth/logout'], + ['get', '/auth/usage'], + ['post', '/auth/feedback'], + ['get', '/auth/feedback'], + ['get', '/auth/feedback/board'], + ['get', '/auth/feedback/:feedbackId'], + ], + ); + assert.equal( + setup.route( + 'post', + '/auth/feedback', + ).handlers[0], + setup.jsonBody, + ); + assert.equal( + setup.route('post', '/auth/logout').handlers + .length, + 1, + ); +}); + +test('preserves account profile, subscription, capability, and skill projection', async () => { + const setup = createSetup(); + const res = await invoke( + setup, + 'get', + '/auth/me', + ); + assert.deepEqual(res.body, { + user: { + id: 'user-1', + username: 'john', + subscription: { + planType: 'pro', + }, + }, + paths: ['/workspace'], + capabilities: ['chat'], + grantedSkills: ['web'], + unrestricted: false, + skillRuntime: { + enabled: true, + }, + agentCodeRun: { + enabled: true, + userId: 'user-1', + }, + }); + + setup.setSubscriptionService(null); + const noSubscription = await invoke( + setup, + 'get', + '/auth/me', + ); + assert.equal( + noSubscription.body.user.subscription, + null, + ); +}); + +test('preserves account availability and authentication gates', async () => { + const unavailable = createSetup(); + unavailable.setUserAuth(null); + const unavailableRes = await invoke( + unavailable, + 'get', + '/auth/me', + ); + assert.equal(unavailableRes.statusCode, 503); + + const unauthorized = createSetup(); + unauthorized.setUserAuth({ + async getMe() { + return null; + }, + }); + const unauthorizedRes = await invoke( + unauthorized, + 'get', + '/auth/me', + ); + assert.equal(unauthorizedRes.statusCode, 401); +}); + +test('preserves user and legacy logout revocation and cookie clearing', async () => { + const setup = createSetup(); + const res = await invoke( + setup, + 'post', + '/auth/logout', + ); + assert.equal(res.statusCode, 204); + assert.equal(res.ended, true); + assert.deepEqual( + setup.calls.filter(([name]) => + [ + 'user-revoke', + 'legacy-revoke', + 'clear-cookies', + ].includes(name), + ), + [ + ['user-revoke', 'request-user-token'], + [ + 'legacy-revoke', + 'request-legacy-token', + ], + ['clear-cookies'], + ], + ); + + const disabled = createSetup(); + disabled.setUserAuth(null); + disabled.setLegacyAuth(null); + const disabledRes = await invoke( + disabled, + 'post', + '/auth/logout', + ); + assert.equal(disabledRes.statusCode, 204); + assert.equal( + disabled.calls.some( + ([name]) => name === 'clear-cookies', + ), + false, + ); +}); + +test('preserves usage record scope and limit', async () => { + const setup = createSetup(); + const res = await invoke( + setup, + 'get', + '/auth/usage', + ); + assert.deepEqual(res.body, { + records: [{ id: 'usage-1' }], + }); + assert.ok( + setup.calls.some( + (call) => + call[0] === 'usage' && + call[1].userId === 'user-1' && + call[1].limit === 30, + ), + ); +}); + +test('preserves feedback submission payload, status, and error mapping', async () => { + const setup = createSetup(); + const body = { + type: 'bug', + title: '页面异常', + description: '详情', + contact: 'john@example.com', + images: ['asset-1'], + context: { path: '/chat' }, + ignored: 'internal', + }; + const res = await invoke( + setup, + 'post', + '/auth/feedback', + { body }, + ); + assert.equal(res.statusCode, 201); + assert.equal(res.body.feedback.id, 'feedback-1'); + assert.deepEqual( + setup.calls.find( + ([name]) => name === 'feedback-submit', + ), + [ + 'feedback-submit', + 'user-1', + { + type: 'bug', + title: '页面异常', + description: '详情', + contact: 'john@example.com', + images: ['asset-1'], + context: { path: '/chat' }, + }, + ], + ); + + const invalid = createSetup(); + invalid.setFeedbackService({ + async submit() { + const error = new Error('标题不能为空'); + error.code = 'invalid_input'; + throw error; + }, + }); + const invalidRes = await invoke( + invalid, + 'post', + '/auth/feedback', + ); + assert.equal(invalidRes.statusCode, 400); + assert.deepEqual(invalidRes.body, { + message: '标题不能为空', + }); + + const failing = createSetup(); + failing.setFeedbackService({ + async submit() { + throw new Error('storage down'); + }, + }); + const failingRes = await invoke( + failing, + 'post', + '/auth/feedback', + ); + assert.equal(failingRes.statusCode, 500); + assert.ok( + failing.calls.some( + ([name]) => name === 'warn', + ), + ); +}); + +test('preserves personal feedback limits and board pagination bounds', async () => { + const setup = createSetup(); + const personal = await invoke( + setup, + 'get', + '/auth/feedback', + { + query: { limit: '1000' }, + }, + ); + assert.deepEqual(personal.body, { + items: [{ id: 'feedback-1' }], + }); + assert.ok( + setup.calls.some( + (call) => + call[0] === 'feedback-list-user' && + call[2].limit === 50, + ), + ); + + const board = await invoke( + setup, + 'get', + '/auth/feedback/board', + { + query: { + page: '-4', + limit: '50', + }, + }, + ); + assert.deepEqual(board.body, { + items: [{ id: 'feedback-1' }], + page: 1, + limit: 10, + }); +}); + +test('preserves feedback detail ownership and not-found mapping', async () => { + const setup = createSetup(); + const res = await invoke( + setup, + 'get', + '/auth/feedback/:feedbackId', + { + params: { + feedbackId: 'feedback-1', + }, + }, + ); + assert.deepEqual(res.body, { + item: { + id: 'feedback-1', + userId: 'user-1', + }, + isMine: true, + }); + + const missing = createSetup(); + missing.setFeedbackService({ + async getById() { + const error = new Error('反馈不存在'); + error.code = 'feedback_not_found'; + throw error; + }, + }); + const missingRes = await invoke( + missing, + 'get', + '/auth/feedback/:feedbackId', + { + params: { + feedbackId: 'missing', + }, + }, + ); + assert.equal(missingRes.statusCode, 404); + assert.deepEqual(missingRes.body, { + message: '反馈不存在', + }); +}); diff --git a/server/portal-agent-job-routes.mjs b/server/portal-agent-job-routes.mjs new file mode 100644 index 0000000..5211e95 --- /dev/null +++ b/server/portal-agent-job-routes.mjs @@ -0,0 +1,306 @@ +import fs from 'node:fs'; + +const TERMINAL_AGENT_JOB_STATUSES = new Set([ + 'completed', + 'failed', + 'cancelled', + 'timed_out', +]); + +function assertRouter(api) { + if ( + !api || + typeof api.get !== 'function' || + typeof api.post !== 'function' + ) { + throw new Error( + 'attachPortalAgentJobRoutes requires an Express-compatible router', + ); + } +} + +export function attachPortalAgentJobRoutes( + api, + { + getMindSpaceAgentJobs = () => null, + getMindSpaceAgentRunner = () => null, + getMindSpaceAudit = () => null, + ensureMindSpaceEnabled = () => false, + requireInternalAgentSecret = () => false, + bearerToken = () => null, + sendData = (res, _req, data, status = 200) => + res.status(status).json({ data }), + handleMindSpaceError = (_res, _req, error) => { + throw error; + }, + readFile = fs.promises.readFile, + getSsePollMs = () => 1_000, + setIntervalFn = setInterval, + clearIntervalFn = clearInterval, + logger = console, + } = {}, +) { + assertRouter(api); + + api.post('/mindspace/v1/agent/jobs', async (req, res) => { + const agentJobs = getMindSpaceAgentJobs(); + if (!agentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; + try { + const job = await agentJobs.createJob(req.currentUser.id, { + jobType: req.body?.job_type, + instruction: req.body?.instruction, + allowedAssetIds: req.body?.allowed_asset_ids, + outputCategoryId: req.body?.output_category_id, + outputType: req.body?.output_type, + idempotencyKey: req.body?.idempotency_key, + locale: req.body?.locale, + timezone: req.body?.timezone, + capabilities: req.body?.capabilities, + }); + await getMindSpaceAudit()?.write({ + userId: req.currentUser.id, + action: 'agent_access', + objectType: 'agent_job', + objectId: job.id, + ip: req.ip, + detail: { + jobType: job.jobType, + assetIds: job.assets.map((asset) => asset.assetId), + }, + }); + return sendData(res, req, job, 201); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.get('/mindspace/v1/agent/jobs/:jobId', async (req, res) => { + const agentJobs = getMindSpaceAgentJobs(); + if (!agentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; + try { + return sendData( + res, + req, + await agentJobs.getJob(req.currentUser.id, req.params.jobId), + ); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + // Long-running jobs are dispatched asynchronously and observed through this + // SSE stream. Ownership remains enforced by the injected getJob service. + api.get('/mindspace/v1/agent/jobs/:jobId/stream', async (req, res) => { + const agentJobs = getMindSpaceAgentJobs(); + if (!agentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; + let job; + try { + job = await agentJobs.getJob(req.currentUser.id, req.params.jobId); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + const send = (event, payload) => { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + let lastSignature = ''; + const emitIfChanged = (current) => { + const signature = `${current.status}:${JSON.stringify(current.progress ?? {})}`; + if (signature !== lastSignature) { + lastSignature = signature; + send('progress', current); + } + return signature; + }; + emitIfChanged(job); + if (TERMINAL_AGENT_JOB_STATUSES.has(job.status)) { + send('done', job); + return res.end(); + } + let closed = false; + const cleanup = () => { + if (closed) return; + closed = true; + clearIntervalFn(pollTimer); + clearIntervalFn(keepAliveTimer); + }; + const pollTimer = setIntervalFn(async () => { + if (closed) return; + try { + const current = await agentJobs.getJob( + req.currentUser.id, + req.params.jobId, + ); + emitIfChanged(current); + if (TERMINAL_AGENT_JOB_STATUSES.has(current.status)) { + send('done', current); + cleanup(); + res.end(); + } + } catch { + cleanup(); + res.end(); + } + }, getSsePollMs()); + const keepAliveTimer = setIntervalFn(() => { + if (!closed) res.write(': keep-alive\n\n'); + }, 15_000); + pollTimer.unref?.(); + keepAliveTimer.unref?.(); + req.on('close', cleanup); + }); + + api.get('/mindspace/v1/agent/jobs', async (req, res) => { + const agentJobs = getMindSpaceAgentJobs(); + if (!agentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; + try { + const result = await agentJobs.listJobs(req.currentUser.id, { + limit: Number(req.query.limit ?? 10), + offset: Number(req.query.offset ?? 0), + }); + return res.json({ + data: result.items, + page: { + total: result.total, + offset: result.offset, + limit: result.limit, + has_more: result.hasMore, + }, + request_id: req.requestId, + }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + for (const [route, operation] of [ + ['/mindspace/v1/agent/jobs/:jobId/cancel', 'cancelJob'], + ['/mindspace/v1/agent/jobs/:jobId/retry', 'retryJob'], + ]) { + api.post(route, async (req, res) => { + const agentJobs = getMindSpaceAgentJobs(); + if (!agentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; + try { + return sendData( + res, + req, + await agentJobs[operation](req.currentUser.id, req.params.jobId), + ); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + } + + api.post('/mindspace/v1/agent/jobs/:jobId/run', async (req, res) => { + const agentJobs = getMindSpaceAgentJobs(); + const agentRunner = getMindSpaceAgentRunner(); + if ( + !agentJobs || + !agentRunner || + !ensureMindSpaceEnabled(res, req, { agent: true }) + ) { + return; + } + try { + const job = await agentJobs.getJob(req.currentUser.id, req.params.jobId); + if (job.status !== 'queued') { + return sendData(res, req, job); + } + void agentRunner.runJob(req.params.jobId).catch((error) => { + logger.error('MindSpace agent job run failed:', error); + }); + return sendData( + res, + req, + { started: true, jobId: req.params.jobId }, + 202, + ); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.post('/internal/agent/jobs/:jobId/claim', async (req, res) => { + const agentJobs = getMindSpaceAgentJobs(); + if (!agentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; + if (!requireInternalAgentSecret(req, res)) return; + try { + return sendData(res, req, await agentJobs.claimJob(req.params.jobId)); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.get('/internal/agent/jobs/:jobId/assets/:assetId', async (req, res) => { + const agentJobs = getMindSpaceAgentJobs(); + if (!agentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; + try { + const asset = await agentJobs.getAssetForJob( + req.params.jobId, + bearerToken(req), + req.params.assetId, + ); + res.set('Content-Type', asset.mimeType); + res.set( + 'Content-Disposition', + `inline; filename="${encodeURIComponent(asset.displayName)}"`, + ); + res.set('Cache-Control', 'private, no-store'); + res.setHeader('X-Request-Id', req.requestId); + return res.send(await readFile(asset.path)); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.post('/internal/agent/jobs/:jobId/heartbeat', async (req, res) => { + const agentJobs = getMindSpaceAgentJobs(); + if (!agentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; + try { + return sendData( + res, + req, + await agentJobs.heartbeat(req.params.jobId, bearerToken(req), { + stage: req.body?.stage, + message: req.body?.message, + }), + ); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.post('/internal/agent/jobs/:jobId/complete', async (req, res) => { + const agentJobs = getMindSpaceAgentJobs(); + if (!agentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; + try { + const job = await agentJobs.completeJob( + req.params.jobId, + bearerToken(req), + { + status: req.body?.status, + errorCode: req.body?.error_code, + errorMessage: req.body?.error_message, + outputType: req.body?.output_type, + title: req.body?.title, + summary: req.body?.summary, + content: req.body?.content, + contentFormat: req.body?.content_format, + pageType: req.body?.page_type, + templateId: req.body?.template_id, + sourceAssetIds: req.body?.source_asset_ids, + }, + ); + return sendData(res, req, job); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); +} diff --git a/server/portal-agent-job-routes.test.mjs b/server/portal-agent-job-routes.test.mjs new file mode 100644 index 0000000..4cc6e90 --- /dev/null +++ b/server/portal-agent-job-routes.test.mjs @@ -0,0 +1,549 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalAgentJobRoutes } from './portal-agent-job-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + get(path, handler) { + routes.set(`GET ${path}`, handler); + }, + post(path, handler) { + routes.set(`POST ${path}`, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + headers: {}, + writes: [], + ended: false, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + writeHead(code, headers) { + this.statusCode = code; + Object.assign(this.headers, headers); + return this; + }, + write(chunk) { + this.writes.push(chunk); + return true; + }, + end() { + this.ended = true; + return this; + }, + set(name, value) { + this.headers[name] = value; + return this; + }, + setHeader(name, value) { + this.headers[name] = value; + }, + send(body) { + this.body = body; + return this; + }, + }; +} + +function createRequest(overrides = {}) { + const listeners = {}; + return { + body: {}, + query: {}, + params: {}, + currentUser: { id: 'user-1' }, + ip: '127.0.0.1', + requestId: 'request-1', + listeners, + on(event, handler) { + listeners[event] = handler; + }, + get() { + return undefined; + }, + ...overrides, + }; +} + +function createDependencies(overrides = {}) { + const calls = { + data: [], + errors: [], + }; + return { + calls, + dependencies: { + ensureMindSpaceEnabled: () => true, + sendData(res, req, data, status = 200) { + calls.data.push({ res, req, data, status }); + return res.status(status).json({ data }); + }, + handleMindSpaceError(res, req, error) { + calls.errors.push({ res, req, error }); + return res.status(418).json({ mapped: error.message }); + }, + ...overrides, + }, + }; +} + +test('Agent Job module preserves route inventory and order', () => { + const api = createRouterRecorder(); + attachPortalAgentJobRoutes(api); + assert.deepEqual([...api.routes.keys()], [ + 'POST /mindspace/v1/agent/jobs', + 'GET /mindspace/v1/agent/jobs/:jobId', + 'GET /mindspace/v1/agent/jobs/:jobId/stream', + 'GET /mindspace/v1/agent/jobs', + 'POST /mindspace/v1/agent/jobs/:jobId/cancel', + 'POST /mindspace/v1/agent/jobs/:jobId/retry', + 'POST /mindspace/v1/agent/jobs/:jobId/run', + 'POST /internal/agent/jobs/:jobId/claim', + 'GET /internal/agent/jobs/:jobId/assets/:assetId', + 'POST /internal/agent/jobs/:jobId/heartbeat', + 'POST /internal/agent/jobs/:jobId/complete', + ]); +}); + +test('create job preserves request mapping, audit, status, and error mapping', async () => { + const calls = []; + const job = { + id: 'job-1', + jobType: 'summarize', + assets: [{ assetId: 'asset-1' }], + }; + const api = createRouterRecorder(); + const success = createDependencies({ + getMindSpaceAgentJobs: () => ({ + async createJob(userId, input) { + calls.push({ kind: 'create', userId, input }); + return job; + }, + }), + getMindSpaceAudit: () => ({ + async write(entry) { + calls.push({ kind: 'audit', entry }); + }, + }), + }); + attachPortalAgentJobRoutes(api, success.dependencies); + const req = createRequest({ + ip: '10.0.0.3', + body: { + job_type: 'summarize', + instruction: 'Summarize', + allowed_asset_ids: ['asset-1'], + output_category_id: 'category-1', + output_type: 'page', + idempotency_key: 'key-1', + locale: 'zh-CN', + timezone: 'Asia/Shanghai', + capabilities: ['read'], + }, + }); + const res = createResponseRecorder(); + await api.routes.get('POST /mindspace/v1/agent/jobs')(req, res); + assert.deepEqual(calls[0], { + kind: 'create', + userId: 'user-1', + input: { + jobType: 'summarize', + instruction: 'Summarize', + allowedAssetIds: ['asset-1'], + outputCategoryId: 'category-1', + outputType: 'page', + idempotencyKey: 'key-1', + locale: 'zh-CN', + timezone: 'Asia/Shanghai', + capabilities: ['read'], + }, + }); + assert.deepEqual(calls[1], { + kind: 'audit', + entry: { + userId: 'user-1', + action: 'agent_access', + objectType: 'agent_job', + objectId: 'job-1', + ip: '10.0.0.3', + detail: { jobType: 'summarize', assetIds: ['asset-1'] }, + }, + }); + assert.equal(success.calls.data[0].status, 201); + assert.equal(success.calls.data[0].data, job); + + const failureApi = createRouterRecorder(); + const failureError = new Error('create failed'); + const failure = createDependencies({ + getMindSpaceAgentJobs: () => ({ + async createJob() { + throw failureError; + }, + }), + }); + attachPortalAgentJobRoutes(failureApi, failure.dependencies); + const failureReq = createRequest(); + const failureRes = createResponseRecorder(); + await failureApi.routes.get('POST /mindspace/v1/agent/jobs')( + failureReq, + failureRes, + ); + assert.deepEqual(failure.calls.errors[0], { + res: failureRes, + req: failureReq, + error: failureError, + }); +}); + +test('get, list, cancel, and retry preserve service forwarding and envelopes', async () => { + const calls = []; + const agentJobs = { + async getJob(userId, jobId) { + calls.push({ kind: 'get', userId, jobId }); + return { id: jobId }; + }, + async listJobs(userId, page) { + calls.push({ kind: 'list', userId, page }); + return { + items: [{ id: 'job-1' }], + total: 11, + offset: page.offset, + limit: page.limit, + hasMore: true, + }; + }, + async cancelJob(userId, jobId) { + calls.push({ kind: 'cancel', userId, jobId }); + return { id: jobId, status: 'cancelled' }; + }, + async retryJob(userId, jobId) { + calls.push({ kind: 'retry', userId, jobId }); + return { id: jobId, status: 'queued' }; + }, + }; + const api = createRouterRecorder(); + const deps = createDependencies({ + getMindSpaceAgentJobs: () => agentJobs, + }); + attachPortalAgentJobRoutes(api, deps.dependencies); + + await api.routes.get('GET /mindspace/v1/agent/jobs/:jobId')( + createRequest({ params: { jobId: 'job-1' } }), + createResponseRecorder(), + ); + const listRes = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/agent/jobs')( + createRequest({ query: { limit: '5', offset: '6' } }), + listRes, + ); + await api.routes.get('POST /mindspace/v1/agent/jobs/:jobId/cancel')( + createRequest({ params: { jobId: 'job-2' } }), + createResponseRecorder(), + ); + await api.routes.get('POST /mindspace/v1/agent/jobs/:jobId/retry')( + createRequest({ params: { jobId: 'job-3' } }), + createResponseRecorder(), + ); + + assert.deepEqual(calls, [ + { kind: 'get', userId: 'user-1', jobId: 'job-1' }, + { kind: 'list', userId: 'user-1', page: { limit: 5, offset: 6 } }, + { kind: 'cancel', userId: 'user-1', jobId: 'job-2' }, + { kind: 'retry', userId: 'user-1', jobId: 'job-3' }, + ]); + assert.deepEqual(listRes.body, { + data: [{ id: 'job-1' }], + page: { + total: 11, + offset: 6, + limit: 5, + has_more: true, + }, + request_id: 'request-1', + }); +}); + +test('terminal SSE job emits progress and done without allocating timers', async () => { + let timerCalls = 0; + const job = { id: 'job-1', status: 'completed', progress: { percent: 100 } }; + const api = createRouterRecorder(); + const deps = createDependencies({ + getMindSpaceAgentJobs: () => ({ getJob: async () => job }), + setIntervalFn: () => { + timerCalls += 1; + return {}; + }, + }); + attachPortalAgentJobRoutes(api, deps.dependencies); + const res = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/agent/jobs/:jobId/stream')( + createRequest({ params: { jobId: 'job-1' } }), + res, + ); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['Content-Type'], 'text/event-stream'); + assert.deepEqual(res.writes, [ + 'event: progress\n', + `data: ${JSON.stringify(job)}\n\n`, + 'event: done\n', + `data: ${JSON.stringify(job)}\n\n`, + ]); + assert.equal(res.ended, true); + assert.equal(timerCalls, 0); +}); + +test('active SSE job polls changes, emits keepalive, and cleans both timers', async () => { + const initial = { id: 'job-1', status: 'running', progress: { percent: 1 } }; + const completed = { + id: 'job-1', + status: 'completed', + progress: { percent: 100 }, + }; + let getCalls = 0; + const timers = []; + const cleared = []; + const api = createRouterRecorder(); + const deps = createDependencies({ + getMindSpaceAgentJobs: () => ({ + async getJob() { + getCalls += 1; + return getCalls === 1 ? initial : completed; + }, + }), + getSsePollMs: () => 321, + setIntervalFn(callback, delay) { + const handle = { callback, delay, unrefCalls: 0 }; + handle.unref = () => { + handle.unrefCalls += 1; + }; + timers.push(handle); + return handle; + }, + clearIntervalFn(handle) { + cleared.push(handle); + }, + }); + attachPortalAgentJobRoutes(api, deps.dependencies); + const req = createRequest({ params: { jobId: 'job-1' } }); + const res = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/agent/jobs/:jobId/stream')(req, res); + assert.deepEqual( + timers.map((timer) => timer.delay), + [321, 15_000], + ); + assert.deepEqual( + timers.map((timer) => timer.unrefCalls), + [1, 1], + ); + timers[1].callback(); + assert.equal(res.writes.at(-1), ': keep-alive\n\n'); + await timers[0].callback(); + assert.equal(res.ended, true); + assert.deepEqual(cleared, timers); + assert.match(res.writes.join(''), /event: done/); + req.listeners.close(); + assert.equal(cleared.length, 2); +}); + +test('run route preserves queued dispatch, existing state, and background error log', async () => { + const logs = []; + let status = 'completed'; + const api = createRouterRecorder(); + const deps = createDependencies({ + getMindSpaceAgentJobs: () => ({ + async getJob(_userId, jobId) { + return { id: jobId, status }; + }, + }), + getMindSpaceAgentRunner: () => ({ + async runJob(jobId) { + assert.equal(jobId, 'job-1'); + throw new Error('runner failed'); + }, + }), + logger: { + error(...args) { + logs.push(args); + }, + }, + }); + attachPortalAgentJobRoutes(api, deps.dependencies); + const handler = api.routes.get('POST /mindspace/v1/agent/jobs/:jobId/run'); + + const completedRes = createResponseRecorder(); + await handler( + createRequest({ params: { jobId: 'job-1' } }), + completedRes, + ); + assert.equal(deps.calls.data[0].data.status, 'completed'); + assert.equal(deps.calls.data[0].status, 200); + + status = 'queued'; + const queuedRes = createResponseRecorder(); + await handler(createRequest({ params: { jobId: 'job-1' } }), queuedRes); + await Promise.resolve(); + assert.deepEqual(deps.calls.data[1], { + res: queuedRes, + req: deps.calls.data[1].req, + data: { started: true, jobId: 'job-1' }, + status: 202, + }); + assert.equal(logs.length, 1); + assert.equal(logs[0][0], 'MindSpace agent job run failed:'); +}); + +test('internal claim preserves secret gate and claim forwarding', async () => { + let allowed = false; + let claims = 0; + const api = createRouterRecorder(); + const deps = createDependencies({ + getMindSpaceAgentJobs: () => ({ + async claimJob(jobId) { + claims += 1; + return { id: jobId, token: 'worker-token' }; + }, + }), + requireInternalAgentSecret: (_req, res) => { + if (allowed) return true; + res.status(401).json({ message: 'denied' }); + return false; + }, + }); + attachPortalAgentJobRoutes(api, deps.dependencies); + const handler = api.routes.get('POST /internal/agent/jobs/:jobId/claim'); + const deniedRes = createResponseRecorder(); + await handler(createRequest({ params: { jobId: 'job-1' } }), deniedRes); + assert.equal(deniedRes.statusCode, 401); + assert.equal(claims, 0); + + allowed = true; + await handler( + createRequest({ params: { jobId: 'job-1' } }), + createResponseRecorder(), + ); + assert.equal(claims, 1); + assert.equal(deps.calls.data[0].data.token, 'worker-token'); +}); + +test('internal asset preserves token, headers, file read, and binary response', async () => { + let received = null; + const body = Buffer.from('asset-body'); + const api = createRouterRecorder(); + const deps = createDependencies({ + getMindSpaceAgentJobs: () => ({ + async getAssetForJob(...args) { + received = args; + return { + mimeType: 'text/plain', + displayName: 'report one.txt', + path: '/tmp/report-one.txt', + }; + }, + }), + bearerToken: () => 'worker-token', + readFile: async (path) => { + assert.equal(path, '/tmp/report-one.txt'); + return body; + }, + }); + attachPortalAgentJobRoutes(api, deps.dependencies); + const res = createResponseRecorder(); + await api.routes.get('GET /internal/agent/jobs/:jobId/assets/:assetId')( + createRequest({ + params: { jobId: 'job-1', assetId: 'asset-1' }, + requestId: 'request-asset', + }), + res, + ); + assert.deepEqual(received, ['job-1', 'worker-token', 'asset-1']); + assert.deepEqual(res.headers, { + 'Content-Type': 'text/plain', + 'Content-Disposition': 'inline; filename="report%20one.txt"', + 'Cache-Control': 'private, no-store', + 'X-Request-Id': 'request-asset', + }); + assert.equal(res.body, body); +}); + +test('heartbeat and complete preserve worker token and payload mapping', async () => { + const calls = []; + const api = createRouterRecorder(); + const deps = createDependencies({ + getMindSpaceAgentJobs: () => ({ + async heartbeat(...args) { + calls.push({ kind: 'heartbeat', args }); + return { ok: true }; + }, + async completeJob(...args) { + calls.push({ kind: 'complete', args }); + return { id: args[0], status: args[2].status }; + }, + }), + bearerToken: () => 'worker-token', + }); + attachPortalAgentJobRoutes(api, deps.dependencies); + await api.routes.get('POST /internal/agent/jobs/:jobId/heartbeat')( + createRequest({ + params: { jobId: 'job-1' }, + body: { stage: 'render', message: 'working' }, + }), + createResponseRecorder(), + ); + await api.routes.get('POST /internal/agent/jobs/:jobId/complete')( + createRequest({ + params: { jobId: 'job-1' }, + body: { + status: 'completed', + error_code: 'none', + error_message: '', + output_type: 'page', + title: 'Title', + summary: 'Summary', + content: '', + content_format: 'html', + page_type: 'report', + template_id: 'template-1', + source_asset_ids: ['asset-1'], + }, + }), + createResponseRecorder(), + ); + assert.deepEqual(calls, [ + { + kind: 'heartbeat', + args: ['job-1', 'worker-token', { stage: 'render', message: 'working' }], + }, + { + kind: 'complete', + args: [ + 'job-1', + 'worker-token', + { + status: 'completed', + errorCode: 'none', + errorMessage: '', + outputType: 'page', + title: 'Title', + summary: 'Summary', + content: '', + contentFormat: 'html', + pageType: 'report', + templateId: 'template-1', + sourceAssetIds: ['asset-1'], + }, + ], + }, + ]); +}); diff --git a/server/portal-agent-runtime-routes.mjs b/server/portal-agent-runtime-routes.mjs new file mode 100644 index 0000000..d147b8e --- /dev/null +++ b/server/portal-agent-runtime-routes.mjs @@ -0,0 +1,172 @@ +import { + createAgentRunEventsHandler, + createGetAgentRunHandler, + createPostAgentRunsHandler, +} from '../agent-run-routes.mjs'; + +function assertRouter(api) { + if ( + !api || + typeof api.get !== 'function' || + typeof api.post !== 'function' + ) { + throw new Error( + 'attachPortalAgentRuntimeRoutes requires an Express-compatible router', + ); + } +} + +function runHandlerChain(chain, req, res, next) { + let index = 0; + const run = (error) => { + if (error) return next(error); + const layer = chain[index++]; + if (!layer) return; + layer(req, res, run); + }; + run(); +} + +export function attachPortalAgentRuntimeRoutes( + api, + { + waitForUserAuthReady = async () => {}, + getUserAuth = () => null, + getTkmindProxy = () => null, + getLlmProviderService = () => null, + getAgentRunGateway = () => null, + getSessionAccess = () => null, + getMindSpaceAssetAgent = () => null, + getCodeRunPolicyService = () => null, + getUserToken = () => null, + ownsAgentSession = async () => false, + createPostAgentRunsHandlerFn = createPostAgentRunsHandler, + createGetAgentRunHandlerFn = createGetAgentRunHandler, + createAgentRunEventsHandlerFn = createAgentRunEventsHandler, + } = {}, +) { + assertRouter(api); + + api.post('/llm/apply-local-fallback', async (req, res) => { + // Client calls after creditsExhausted or relay 500 (see useTKMindChat). + await waitForUserAuthReady(); + const userAuth = getUserAuth(); + const llmProviderService = getLlmProviderService(); + const tkmindProxy = getTkmindProxy(); + if (!userAuth || !llmProviderService || !tkmindProxy) { + return res.status(503).json({ message: '未启用 LLM 配置' }); + } + const me = await userAuth.getMe(getUserToken(req)); + if (!me) return res.status(401).json({ message: '未登录' }); + const sessionId = String(req.body?.session_id ?? '').trim(); + if (!sessionId) return res.status(400).json({ message: '缺少 session_id' }); + const owns = await ownsAgentSession(me.id, sessionId); + if (!owns) return res.status(403).json({ message: '无权访问该会话' }); + try { + const result = await tkmindProxy.applyLocalFallbackForSession(sessionId); + if (!result.ok) return res.status(503).json(result); + return res.json(result); + } catch (error) { + return res.status(500).json({ + message: error instanceof Error ? error.message : '切换本地 LLM 失败', + }); + } + }); + + api.post('/agent/start', async (req, res, next) => { + await waitForUserAuthReady(); + const userAuth = getUserAuth(); + const tkmindProxy = getTkmindProxy(); + if (!userAuth || !tkmindProxy) return next(); + return runHandlerChain( + tkmindProxy.handlers['POST /agent/start'], + req, + res, + next, + ); + }); + + api.post('/agent/runs', async (req, res, next) => { + await waitForUserAuthReady(); + const userAuth = getUserAuth(); + const tkmindProxy = getTkmindProxy(); + const agentRunGateway = getAgentRunGateway(); + if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); + return runHandlerChain( + [ + tkmindProxy.requireUser, + tkmindProxy.ensureChatAllowed, + createPostAgentRunsHandlerFn({ + userAuth, + sessionAccess: getSessionAccess(), + agentRunGateway, + mindSpaceAssetAgent: getMindSpaceAssetAgent(), + codeRunPolicyService: getCodeRunPolicyService(), + }), + ], + req, + res, + next, + ); + }); + + api.get('/agent/runs/:runId', async (req, res, next) => { + await waitForUserAuthReady(); + const userAuth = getUserAuth(); + const tkmindProxy = getTkmindProxy(); + const agentRunGateway = getAgentRunGateway(); + if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); + return runHandlerChain( + [ + tkmindProxy.requireUser, + createGetAgentRunHandlerFn({ agentRunGateway }), + ], + req, + res, + next, + ); + }); + + api.get('/agent/runs/:runId/events', async (req, res, next) => { + await waitForUserAuthReady(); + const userAuth = getUserAuth(); + const tkmindProxy = getTkmindProxy(); + const agentRunGateway = getAgentRunGateway(); + if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); + return runHandlerChain( + [ + tkmindProxy.requireUser, + createAgentRunEventsHandlerFn({ agentRunGateway }), + ], + req, + res, + next, + ); + }); + + api.post('/agent/resume', async (req, res, next) => { + await waitForUserAuthReady(); + const userAuth = getUserAuth(); + const tkmindProxy = getTkmindProxy(); + if (!userAuth || !tkmindProxy) return next(); + return runHandlerChain( + tkmindProxy.handlers['POST /agent/resume'], + req, + res, + next, + ); + }); + + api.get('/sessions', async (req, res, next) => { + await waitForUserAuthReady(); + const userAuth = getUserAuth(); + const tkmindProxy = getTkmindProxy(); + if (!userAuth || !tkmindProxy) return next(); + return runHandlerChain( + tkmindProxy.handlers['GET /sessions'], + req, + res, + next, + ); + }); +} diff --git a/server/portal-agent-runtime-routes.test.mjs b/server/portal-agent-runtime-routes.test.mjs new file mode 100644 index 0000000..1d3e7d5 --- /dev/null +++ b/server/portal-agent-runtime-routes.test.mjs @@ -0,0 +1,311 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalAgentRuntimeRoutes } from './portal-agent-runtime-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + get(path, handler) { + routes.set(`GET ${path}`, handler); + }, + post(path, handler) { + routes.set(`POST ${path}`, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +function createRequest(overrides = {}) { + return { + body: {}, + params: {}, + query: {}, + ...overrides, + }; +} + +function createProxy(calls) { + const handler = (name) => (req, _res, next) => { + calls.push({ kind: 'handler', name, req }); + next(); + }; + return { + requireUser: handler('require-user'), + ensureChatAllowed: handler('ensure-chat-allowed'), + handlers: { + 'POST /agent/start': [handler('start-1'), handler('start-2')], + 'POST /agent/resume': [handler('resume')], + 'GET /sessions': [handler('sessions')], + }, + }; +} + +test('Agent runtime module preserves route inventory and order', () => { + const api = createRouterRecorder(); + attachPortalAgentRuntimeRoutes(api); + assert.deepEqual([...api.routes.keys()], [ + 'POST /llm/apply-local-fallback', + 'POST /agent/start', + 'POST /agent/runs', + 'GET /agent/runs/:runId', + 'GET /agent/runs/:runId/events', + 'POST /agent/resume', + 'GET /sessions', + ]); +}); + +test('local fallback preserves readiness, auth, ownership, and status gates', async () => { + const scenarios = [ + { + name: 'unavailable', + dependencies: {}, + expectedStatus: 503, + expectedBody: { message: '未启用 LLM 配置' }, + }, + { + name: 'signed out', + dependencies: { + getUserAuth: () => ({ getMe: async () => null }), + getLlmProviderService: () => ({}), + getTkmindProxy: () => ({}), + }, + expectedStatus: 401, + expectedBody: { message: '未登录' }, + }, + { + name: 'missing session', + dependencies: { + getUserAuth: () => ({ getMe: async () => ({ id: 'user-1' }) }), + getLlmProviderService: () => ({}), + getTkmindProxy: () => ({}), + }, + expectedStatus: 400, + expectedBody: { message: '缺少 session_id' }, + }, + { + name: 'not owner', + request: createRequest({ body: { session_id: 'session-1' } }), + dependencies: { + getUserAuth: () => ({ getMe: async () => ({ id: 'user-1' }) }), + getLlmProviderService: () => ({}), + getTkmindProxy: () => ({}), + }, + expectedStatus: 403, + expectedBody: { message: '无权访问该会话' }, + }, + ]; + + for (const scenario of scenarios) { + let ready = false; + const api = createRouterRecorder(); + attachPortalAgentRuntimeRoutes(api, { + waitForUserAuthReady: async () => { + ready = true; + }, + ...scenario.dependencies, + }); + const res = createResponseRecorder(); + await api.routes.get('POST /llm/apply-local-fallback')( + scenario.request ?? createRequest(), + res, + ); + assert.equal(ready, true, scenario.name); + assert.equal(res.statusCode, scenario.expectedStatus, scenario.name); + assert.deepEqual(res.body, scenario.expectedBody, scenario.name); + } +}); + +test('local fallback preserves token lookup, success, upstream failure, and exceptions', async () => { + const calls = []; + const proxy = { + async applyLocalFallbackForSession(sessionId) { + calls.push({ kind: 'fallback', sessionId }); + return { ok: true, provider: 'local' }; + }, + }; + const api = createRouterRecorder(); + attachPortalAgentRuntimeRoutes(api, { + getUserAuth: () => ({ + async getMe(token) { + calls.push({ kind: 'get-me', token }); + return { id: 'user-1' }; + }, + }), + getLlmProviderService: () => ({}), + getTkmindProxy: () => proxy, + getUserToken: () => 'token-1', + async ownsAgentSession(userId, sessionId) { + calls.push({ kind: 'owns', userId, sessionId }); + return true; + }, + }); + const req = createRequest({ body: { session_id: ' session-1 ' } }); + const res = createResponseRecorder(); + await api.routes.get('POST /llm/apply-local-fallback')(req, res); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { ok: true, provider: 'local' }); + assert.deepEqual(calls, [ + { kind: 'get-me', token: 'token-1' }, + { kind: 'owns', userId: 'user-1', sessionId: 'session-1' }, + { kind: 'fallback', sessionId: 'session-1' }, + ]); + + proxy.applyLocalFallbackForSession = async () => ({ + ok: false, + reason: 'relay_unavailable', + }); + const unavailableRes = createResponseRecorder(); + await api.routes.get('POST /llm/apply-local-fallback')(req, unavailableRes); + assert.equal(unavailableRes.statusCode, 503); + assert.deepEqual(unavailableRes.body, { + ok: false, + reason: 'relay_unavailable', + }); + + proxy.applyLocalFallbackForSession = async () => { + throw new Error('switch failed'); + }; + const errorRes = createResponseRecorder(); + await api.routes.get('POST /llm/apply-local-fallback')(req, errorRes); + assert.equal(errorRes.statusCode, 500); + assert.deepEqual(errorRes.body, { message: 'switch failed' }); +}); + +test('proxy-backed routes preserve handler order and fall through after completion', async () => { + const calls = []; + const api = createRouterRecorder(); + const proxy = createProxy(calls); + attachPortalAgentRuntimeRoutes(api, { + getUserAuth: () => ({ id: 'auth' }), + getTkmindProxy: () => proxy, + getAgentRunGateway: () => ({ id: 'gateway' }), + getSessionAccess: () => ({ id: 'session-access' }), + getMindSpaceAssetAgent: () => ({ id: 'asset-agent' }), + getCodeRunPolicyService: () => ({ id: 'code-run-policy' }), + createPostAgentRunsHandlerFn(dependencies) { + calls.push({ kind: 'create-post', dependencies }); + return (req, _res, next) => { + calls.push({ kind: 'handler', name: 'post-run', req }); + next(); + }; + }, + createGetAgentRunHandlerFn(dependencies) { + calls.push({ kind: 'create-get', dependencies }); + return (req, _res, next) => { + calls.push({ kind: 'handler', name: 'get-run', req }); + next(); + }; + }, + createAgentRunEventsHandlerFn(dependencies) { + calls.push({ kind: 'create-events', dependencies }); + return (req, _res, next) => { + calls.push({ kind: 'handler', name: 'run-events', req }); + next(); + }; + }, + }); + + for (const route of [ + 'POST /agent/start', + 'POST /agent/runs', + 'GET /agent/runs/:runId', + 'GET /agent/runs/:runId/events', + 'POST /agent/resume', + 'GET /sessions', + ]) { + const req = createRequest({ route }); + let nextCount = 0; + await api.routes.get(route)(req, createResponseRecorder(), () => { + nextCount += 1; + }); + assert.equal(nextCount, 0, `${route} does not fall through after a handled chain`); + } + + assert.deepEqual( + calls.filter((call) => call.kind === 'handler').map((call) => call.name), + [ + 'start-1', + 'start-2', + 'require-user', + 'ensure-chat-allowed', + 'post-run', + 'require-user', + 'get-run', + 'require-user', + 'run-events', + 'resume', + 'sessions', + ], + ); + assert.deepEqual( + calls.find((call) => call.kind === 'create-post').dependencies, + { + userAuth: { id: 'auth' }, + sessionAccess: { id: 'session-access' }, + agentRunGateway: { id: 'gateway' }, + mindSpaceAssetAgent: { id: 'asset-agent' }, + codeRunPolicyService: { id: 'code-run-policy' }, + }, + ); +}); + +test('proxy-backed routes preserve unavailable fallthrough and middleware errors', async () => { + const api = createRouterRecorder(); + attachPortalAgentRuntimeRoutes(api); + for (const route of [ + 'POST /agent/start', + 'POST /agent/runs', + 'GET /agent/runs/:runId', + 'GET /agent/runs/:runId/events', + 'POST /agent/resume', + 'GET /sessions', + ]) { + let nextCount = 0; + await api.routes.get(route)( + createRequest(), + createResponseRecorder(), + () => { + nextCount += 1; + }, + ); + assert.equal(nextCount, 1, route); + } + + const failure = new Error('middleware failed'); + const errorApi = createRouterRecorder(); + attachPortalAgentRuntimeRoutes(errorApi, { + getUserAuth: () => ({}), + getTkmindProxy: () => ({ + handlers: { + 'POST /agent/start': [ + (_req, _res, next) => next(failure), + () => assert.fail('chain must stop after an error'), + ], + }, + }), + }); + let forwarded; + await errorApi.routes.get('POST /agent/start')( + createRequest(), + createResponseRecorder(), + (error) => { + forwarded = error; + }, + ); + assert.equal(forwarded, failure); +}); diff --git a/server/portal-agent-services-bootstrap.mjs b/server/portal-agent-services-bootstrap.mjs new file mode 100644 index 0000000..e5b5dfc --- /dev/null +++ b/server/portal-agent-services-bootstrap.mjs @@ -0,0 +1,231 @@ +import { createAssetGatewayConfigService } from '../asset-gateway.mjs'; +import { createExperienceService } from '../experience-service.mjs'; +import { createImageMakeAdminConfigService } from '../image-make-admin-config.mjs'; +import { createImageMakeClientFromEnv } from '../image-make-client.mjs'; +import { createLlmProviderService, RELAY_BOOTSTRAP } from '../llm-providers.mjs'; +import { createMindSpaceAgentRunner } from '../mindspace-agent-runner.mjs'; +import { createMindSpaceAuditWriter } from '../mindspace-audit.mjs'; +import { createMindSpaceImageGenerationService } from '../mindspace-image-generation.mjs'; +import { createMindSpaceImageReviewService } from '../mindspace-image-review.mjs'; +import { createWordFilterService } from '../word-filter.mjs'; + +async function createPgExperienceServiceFromModule(options) { + const { createPgExperienceService } = await import( + '../experience-service-pg.mjs' + ); + return createPgExperienceService(options); +} + +export async function bootstrapPortalAgentServices({ + pool, + env = process.env, + runtime, + apiTarget, + apiTargets, + apiSecret, + userAuth, + sessionAccess, + mindSpaceRuntimeAdapter, + mindSpaceAssets, + resolveUserIdByDirKey, + workspaceMaintenanceEnabled = true, + startWorkspaceThumbnailWatcher, + startWorkspaceAssetSyncWatcher, + logger = console, + createExperienceServiceFn = createExperienceService, + createPgExperienceServiceFn = + createPgExperienceServiceFromModule, + createMindSpaceAgentRunnerFn = + createMindSpaceAgentRunner, + createMindSpaceAuditWriterFn = + createMindSpaceAuditWriter, + createLlmProviderServiceFn = createLlmProviderService, + createAssetGatewayConfigServiceFn = + createAssetGatewayConfigService, + createImageMakeAdminConfigServiceFn = + createImageMakeAdminConfigService, + createImageMakeClientFromEnvFn = + createImageMakeClientFromEnv, + createMindSpaceImageReviewServiceFn = + createMindSpaceImageReviewService, + createMindSpaceImageGenerationServiceFn = + createMindSpaceImageGenerationService, + createWordFilterServiceFn = createWordFilterService, + relayBootstrap = RELAY_BOOTSTRAP, +} = {}) { + if ( + !pool || + !runtime || + !userAuth || + !sessionAccess || + !mindSpaceRuntimeAdapter?.agentJobService || + typeof mindSpaceRuntimeAdapter.startBackgroundJobs !== + 'function' || + !mindSpaceAssets || + typeof resolveUserIdByDirKey !== 'function' || + typeof startWorkspaceThumbnailWatcher !== 'function' || + typeof startWorkspaceAssetSyncWatcher !== 'function' + ) { + throw new Error( + 'bootstrapPortalAgentServices requires runtime service dependencies', + ); + } + + const mindSpaceAgentJobs = + mindSpaceRuntimeAdapter.agentJobService; + let experienceService = null; + if (runtime.experienceEnabled) { + if (env.EXPERIENCE_PG_URL) { + try { + experienceService = + await createPgExperienceServiceFn({ + connectionString: env.EXPERIENCE_PG_URL, + }); + logger.log( + 'Experience store: PostgreSQL + pgvector', + ); + } catch (error) { + logger.error( + 'Experience PG init failed, falling back to MySQL store:', + error instanceof Error ? error.message : error, + ); + experienceService = createExperienceServiceFn(pool); + } + } else { + experienceService = createExperienceServiceFn(pool); + } + } + + const mindSpaceAgentRunner = + createMindSpaceAgentRunnerFn({ + apiTarget, + apiSecret, + userAuth, + sessionAccess, + agentJobService: mindSpaceAgentJobs, + experienceService, + }); + const mindSpaceAudit = + createMindSpaceAuditWriterFn(pool); + + mindSpaceRuntimeAdapter.startBackgroundJobs({ + publicationCleanupIntervalMs: 60 * 1000, + agentWorker: runtime.agentWorker, + agentRunner: mindSpaceAgentRunner, + workspaceMaintenanceEnabled, + publishRoot: runtime.publishRoot, + startWorkspaceThumbnailWatcher, + startWorkspaceAssetSyncWatcher, + syncUserWorkspaceByDirKey: async ( + dirKey, + options, + ) => { + const userId = + await resolveUserIdByDirKey(dirKey); + if (!userId) return; + await mindSpaceAssets.syncWorkspaceAssets( + userId, + options, + ); + }, + expireStaleUploadsIntervalMs: 5 * 60 * 1000, + }); + + await userAuth.ensureAdminUser(); + const userDataSpaceBackfill = + await userAuth.ensureAllUserDataSpaces(); + if (userDataSpaceBackfill.errors.length > 0) { + logger.warn( + `[PageData] PG space backfill incomplete: ${userDataSpaceBackfill.errors.length} user(s) failed`, + ); + } + + const llmProviderService = + createLlmProviderServiceFn(pool, { + apiTarget, + apiTargets, + apiSecret, + }); + const assetGatewayConfigService = + createAssetGatewayConfigServiceFn(pool, { + llmProviderService, + }); + const imageMakeAdminConfigService = + createImageMakeAdminConfigServiceFn(pool, { + env, + llmProviderService, + }); + await imageMakeAdminConfigService.ensureSchema(); + + let imageMakeClient = null; + try { + imageMakeClient = + createImageMakeClientFromEnvFn(env); + } catch (error) { + logger.warn( + '[image_make] invalid local configuration; integration stays disabled:', + error?.message ?? error, + ); + } + const imageReviewService = + createMindSpaceImageReviewServiceFn({ + llmProviderService, + env, + logger, + }); + const mindSpaceImageGeneration = + createMindSpaceImageGenerationServiceFn({ + configService: assetGatewayConfigService, + assetService: mindSpaceAssets, + imageMakeClient, + imageReviewService, + env, + logger, + }); + const wordFilterService = + createWordFilterServiceFn(pool); + + const relayBootstrapTask = llmProviderService + .ensureBootstrapRelay() + .then((result) => { + if (result.created) { + logger.log( + `LLM relay bootstrap created: ${relayBootstrap.name}`, + ); + } + }) + .catch((error) => { + logger.warn( + 'LLM relay bootstrap skipped:', + error instanceof Error ? error.message : error, + ); + }); + void relayBootstrapTask; + + const providerSyncTask = llmProviderService + .syncSelectedToGoosed() + .catch((error) => { + logger.warn( + 'LLM provider boot sync skipped:', + error instanceof Error ? error.message : error, + ); + }); + void providerSyncTask; + + return { + mindSpaceAgentJobs, + experienceService, + mindSpaceAgentRunner, + mindSpaceAudit, + userDataSpaceBackfill, + llmProviderService, + assetGatewayConfigService, + imageMakeAdminConfigService, + imageMakeClient, + imageReviewService, + mindSpaceImageGeneration, + wordFilterService, + relayBootstrapTask, + providerSyncTask, + }; +} diff --git a/server/portal-agent-services-bootstrap.test.mjs b/server/portal-agent-services-bootstrap.test.mjs new file mode 100644 index 0000000..6ce6f62 --- /dev/null +++ b/server/portal-agent-services-bootstrap.test.mjs @@ -0,0 +1,412 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { bootstrapPortalAgentServices } from './portal-agent-services-bootstrap.mjs'; + +function createSetup(overrides = {}) { + const calls = []; + const pool = { id: 'pool' }; + const agentJobService = { id: 'jobs' }; + const mindSpaceAssets = { + async syncWorkspaceAssets(userId, options) { + calls.push([ + 'sync-workspace', + userId, + options, + ]); + }, + }; + let backgroundOptions; + let runnerOptions; + let llmOptions; + let assetConfigOptions; + let imageAdminOptions; + let reviewOptions; + let generationOptions; + const llmProviderService = { + async ensureBootstrapRelay() { + calls.push(['relay-bootstrap']); + return { created: true }; + }, + async syncSelectedToGoosed() { + calls.push(['provider-sync']); + }, + }; + const options = { + pool, + env: { + EXPERIENCE_PG_URL: 'postgres://experience', + }, + runtime: { + experienceEnabled: true, + agentWorker: { enabled: true }, + publishRoot: '/publish', + }, + apiTarget: 'http://api-primary', + apiTargets: [ + 'http://api-primary', + 'http://api-secondary', + ], + apiSecret: 'api-secret', + userAuth: { + async ensureAdminUser() { + calls.push(['ensure-admin']); + }, + async ensureAllUserDataSpaces() { + calls.push(['backfill-user-spaces']); + return { errors: [{ userId: 'failed-user' }] }; + }, + }, + sessionAccess: { id: 'session-access' }, + mindSpaceRuntimeAdapter: { + agentJobService, + startBackgroundJobs(receivedOptions) { + calls.push(['start-background']); + backgroundOptions = receivedOptions; + }, + }, + mindSpaceAssets, + async resolveUserIdByDirKey(dirKey) { + calls.push(['resolve-dir-key', dirKey]); + return dirKey === 'missing' ? null : 'user-1'; + }, + workspaceMaintenanceEnabled: false, + startWorkspaceThumbnailWatcher() {}, + startWorkspaceAssetSyncWatcher() {}, + logger: { + log(...args) { + calls.push(['log', ...args]); + }, + warn(...args) { + calls.push(['warn', ...args]); + }, + error(...args) { + calls.push(['error', ...args]); + }, + }, + createExperienceServiceFn(receivedPool) { + calls.push(['mysql-experience', receivedPool]); + return { id: 'mysql-experience' }; + }, + async createPgExperienceServiceFn(receivedOptions) { + calls.push(['pg-experience', receivedOptions]); + return { id: 'pg-experience' }; + }, + createMindSpaceAgentRunnerFn(receivedOptions) { + calls.push(['agent-runner']); + runnerOptions = receivedOptions; + return { id: 'runner' }; + }, + createMindSpaceAuditWriterFn(receivedPool) { + calls.push(['audit', receivedPool]); + return { id: 'audit' }; + }, + createLlmProviderServiceFn( + receivedPool, + receivedOptions, + ) { + calls.push(['llm-provider', receivedPool]); + llmOptions = receivedOptions; + return llmProviderService; + }, + createAssetGatewayConfigServiceFn( + receivedPool, + receivedOptions, + ) { + calls.push(['asset-config', receivedPool]); + assetConfigOptions = receivedOptions; + return { id: 'asset-config' }; + }, + createImageMakeAdminConfigServiceFn( + receivedPool, + receivedOptions, + ) { + calls.push(['image-admin', receivedPool]); + imageAdminOptions = receivedOptions; + return { + id: 'image-admin', + async ensureSchema() { + calls.push(['image-schema']); + }, + }; + }, + createImageMakeClientFromEnvFn(receivedEnv) { + calls.push(['image-client', receivedEnv]); + return { id: 'image-client' }; + }, + createMindSpaceImageReviewServiceFn( + receivedOptions, + ) { + calls.push(['image-review']); + reviewOptions = receivedOptions; + return { id: 'image-review' }; + }, + createMindSpaceImageGenerationServiceFn( + receivedOptions, + ) { + calls.push(['image-generation']); + generationOptions = receivedOptions; + return { id: 'image-generation' }; + }, + createWordFilterServiceFn(receivedPool) { + calls.push(['word-filter', receivedPool]); + return { id: 'word-filter' }; + }, + relayBootstrap: { name: 'relay-name' }, + ...overrides, + }; + + return { + calls, + options, + pool, + agentJobService, + mindSpaceAssets, + llmProviderService, + getCaptured() { + return { + backgroundOptions, + runnerOptions, + llmOptions, + assetConfigOptions, + imageAdminOptions, + reviewOptions, + generationOptions, + }; + }, + }; +} + +test('requires the Agent runtime service dependencies', async () => { + await assert.rejects( + bootstrapPortalAgentServices(), + /requires runtime service dependencies/, + ); +}); + +test('preserves Agent, background, admin, and LLM assembly order', async () => { + const setup = createSetup(); + const result = await bootstrapPortalAgentServices( + setup.options, + ); + await Promise.all([ + result.relayBootstrapTask, + result.providerSyncTask, + ]); + const captured = setup.getCaptured(); + + assert.deepEqual( + setup.calls.slice(0, 8).map(([name]) => name), + [ + 'pg-experience', + 'log', + 'agent-runner', + 'audit', + 'start-background', + 'ensure-admin', + 'backfill-user-spaces', + 'warn', + ], + ); + assert.equal( + result.mindSpaceAgentJobs, + setup.agentJobService, + ); + assert.deepEqual(captured.runnerOptions, { + apiTarget: 'http://api-primary', + apiSecret: 'api-secret', + userAuth: setup.options.userAuth, + sessionAccess: setup.options.sessionAccess, + agentJobService: setup.agentJobService, + experienceService: { id: 'pg-experience' }, + }); + assert.deepEqual(captured.llmOptions, { + apiTarget: 'http://api-primary', + apiTargets: [ + 'http://api-primary', + 'http://api-secondary', + ], + apiSecret: 'api-secret', + }); + assert.equal( + captured.assetConfigOptions.llmProviderService, + setup.llmProviderService, + ); + assert.equal( + captured.imageAdminOptions.llmProviderService, + setup.llmProviderService, + ); + assert.equal( + captured.imageAdminOptions.env, + setup.options.env, + ); +}); + +test('preserves background workspace synchronization callbacks', async () => { + const setup = createSetup(); + await bootstrapPortalAgentServices(setup.options); + const { backgroundOptions } = setup.getCaptured(); + + assert.equal( + backgroundOptions.publicationCleanupIntervalMs, + 60 * 1000, + ); + assert.equal( + backgroundOptions.expireStaleUploadsIntervalMs, + 5 * 60 * 1000, + ); + assert.equal( + backgroundOptions.agentWorker, + setup.options.runtime.agentWorker, + ); + assert.equal( + backgroundOptions.workspaceMaintenanceEnabled, + false, + ); + assert.equal( + backgroundOptions.publishRoot, + '/publish', + ); + assert.equal( + backgroundOptions.startWorkspaceThumbnailWatcher, + setup.options.startWorkspaceThumbnailWatcher, + ); + assert.equal( + backgroundOptions.startWorkspaceAssetSyncWatcher, + setup.options.startWorkspaceAssetSyncWatcher, + ); + + await backgroundOptions.syncUserWorkspaceByDirKey( + 'known', + { force: true }, + ); + await backgroundOptions.syncUserWorkspaceByDirKey( + 'missing', + { force: false }, + ); + assert.deepEqual( + setup.calls.filter( + ([name]) => name === 'sync-workspace', + ), + [['sync-workspace', 'user-1', { force: true }]], + ); +}); + +test('preserves image service wiring', async () => { + const setup = createSetup(); + const result = await bootstrapPortalAgentServices( + setup.options, + ); + const captured = setup.getCaptured(); + + assert.equal( + captured.reviewOptions.llmProviderService, + setup.llmProviderService, + ); + assert.equal( + captured.reviewOptions.env, + setup.options.env, + ); + assert.equal( + captured.generationOptions.configService, + result.assetGatewayConfigService, + ); + assert.equal( + captured.generationOptions.assetService, + setup.mindSpaceAssets, + ); + assert.equal( + captured.generationOptions.imageMakeClient, + result.imageMakeClient, + ); + assert.equal( + captured.generationOptions.imageReviewService, + result.imageReviewService, + ); + assert.equal( + result.mindSpaceImageGeneration.id, + 'image-generation', + ); + assert.equal(result.wordFilterService.id, 'word-filter'); +}); + +test('falls back from PG experience and degrades invalid image config', async () => { + const setup = createSetup({ + createPgExperienceServiceFn: async () => { + throw new Error('pg unavailable'); + }, + createImageMakeClientFromEnvFn: () => { + throw new Error('bad image config'); + }, + }); + + const result = await bootstrapPortalAgentServices( + setup.options, + ); + + assert.equal(result.experienceService.id, 'mysql-experience'); + assert.equal(result.imageMakeClient, null); + assert.ok( + setup.calls.some( + ([name, message, detail]) => + name === 'error' && + message === + 'Experience PG init failed, falling back to MySQL store:' && + detail === 'pg unavailable', + ), + ); + assert.ok( + setup.calls.some( + ([name, message, detail]) => + name === 'warn' && + message === + '[image_make] invalid local configuration; integration stays disabled:' && + detail === 'bad image config', + ), + ); +}); + +test('keeps disabled experience null and reports background LLM failures', async () => { + const setup = createSetup({ + runtime: { + experienceEnabled: false, + agentWorker: { enabled: false }, + publishRoot: '/publish', + }, + createLlmProviderServiceFn() { + return { + async ensureBootstrapRelay() { + throw new Error('relay failed'); + }, + async syncSelectedToGoosed() { + throw new Error('sync failed'); + }, + }; + }, + }); + + const result = await bootstrapPortalAgentServices( + setup.options, + ); + await Promise.all([ + result.relayBootstrapTask, + result.providerSyncTask, + ]); + + assert.equal(result.experienceService, null); + assert.ok( + setup.calls.some( + ([name, message, detail]) => + name === 'warn' && + message === 'LLM relay bootstrap skipped:' && + detail === 'relay failed', + ), + ); + assert.ok( + setup.calls.some( + ([name, message, detail]) => + name === 'warn' && + message === 'LLM provider boot sync skipped:' && + detail === 'sync failed', + ), + ); +}); diff --git a/server/portal-api-auth-middleware.mjs b/server/portal-api-auth-middleware.mjs new file mode 100644 index 0000000..f8ff425 --- /dev/null +++ b/server/portal-api-auth-middleware.mjs @@ -0,0 +1,136 @@ +import { + comparePortalAccessPolicyShadow, + isPortalLegacyDirectGlobalAuthBypass, + isPortalPlazaOptionalUserPath, + PORTAL_ACCESS_POLICY_MODE, + resolvePortalAccessEnforcementDecision, + shouldOverridePortalLegacyGlobalAuth, +} from './portal-access-policy.mjs'; + +function resolveAuthMode({ userAuth, tkmindProxy, legacyAuth }) { + if (userAuth && tkmindProxy) return 'multi-user'; + if (legacyAuth) return 'legacy'; + return 'none'; +} + +export function createPortalApiAuthMiddleware({ + waitForUserAuthReady = async () => {}, + getUserAuth = () => null, + getTkmindProxy = () => null, + getLegacyAuth = () => null, + getLegacySessionToken = () => null, + isPageDataPublicPath = () => false, + isLegacyPageDataApiPath = () => false, + accessPolicyMode = PORTAL_ACCESS_POLICY_MODE.OFF, + accessEnforcementConfig = Object.freeze({ + masterEnabled: false, + killSwitch: false, + enabled: false, + activeGroups: Object.freeze([]), + }), + accessShadowReporter = null, + logger = console, +} = {}) { + return async function portalApiAuthMiddleware(req, res, next) { + await waitForUserAuthReady(); + + const userAuth = getUserAuth(); + const tkmindProxy = getTkmindProxy(); + const legacyAuth = getLegacyAuth(); + const authMode = resolveAuthMode({ userAuth, tkmindProxy, legacyAuth }); + const requestAccess = { path: req.path, method: req.method }; + const accessPredicates = { + isPageDataPublicPath, + isLegacyPageDataApiPath, + }; + const comparison = + accessPolicyMode === PORTAL_ACCESS_POLICY_MODE.SHADOW || + accessEnforcementConfig.enabled + ? comparePortalAccessPolicyShadow( + requestAccess, + { + multiUserEnabled: Boolean(userAuth && tkmindProxy), + ...accessPredicates, + }, + ) + : null; + + if (accessPolicyMode === PORTAL_ACCESS_POLICY_MODE.SHADOW) { + accessShadowReporter?.record({ + requestId: req.requestId, + authMode, + ...comparison, + }); + } + + if (accessEnforcementConfig.enabled) { + const decision = resolvePortalAccessEnforcementDecision( + requestAccess, + accessPredicates, + accessEnforcementConfig, + ); + if (decision.selected) { + logger.info('[Auth][PortalAccessEnforce]', JSON.stringify({ + requestId: req.requestId, + authMode, + path: req.path, + method: req.method, + ...decision, + legacyGlobalAuthBypass: comparison.currentGlobalAuthBypass, + behaviorChanged: + decision.bypassGlobalAuth !== comparison.currentGlobalAuthBypass, + })); + // Multi-user public routes must continue through the legacy branch so + // an optional authenticated identity is hydrated onto req.currentUser. + if ( + shouldOverridePortalLegacyGlobalAuth( + decision, + comparison.currentGlobalAuthBypass, + ) + ) { + return next(); + } + } + } + + if (isPortalLegacyDirectGlobalAuthBypass(req.path, req.method)) { + return next(); + } + + const plazaPublic = isPortalPlazaOptionalUserPath(req.path, req.method); + const pageDataPublic = isPageDataPublicPath(req.path, req.method); + // The retired namespace must reach its explicit 410 route instead of + // being converted into a misleading global 401/403 response. + const legacyPageDataApi = isLegacyPageDataApiPath(req.path); + + if (userAuth && tkmindProxy) { + if (req.userSessionError) { + if (plazaPublic || pageDataPublic || legacyPageDataApi) return next(); + return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' }); + } + try { + if (req.userSession) { + const me = await userAuth.getMe(req.userToken); + if (me) req.currentUser = me; + } + if (plazaPublic || pageDataPublic || legacyPageDataApi) return next(); + if (!req.userSession) { + return res.status(401).json({ message: '未授权,请重新登录' }); + } + const me = await userAuth.getMe(req.userToken); + if (!me) return res.status(401).json({ message: '登录已过期' }); + req.currentUser = me; + return next(); + } catch (error) { + logger.error( + '[Auth] API auth failed:', + error instanceof Error ? error.message : error, + ); + return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' }); + } + } + + if (legacyAuth?.verify(getLegacySessionToken(req))) return next(); + return res.status(401).json({ message: '未授权,请重新登录' }); + }; +} diff --git a/server/portal-api-auth-middleware.test.mjs b/server/portal-api-auth-middleware.test.mjs new file mode 100644 index 0000000..62e0d09 --- /dev/null +++ b/server/portal-api-auth-middleware.test.mjs @@ -0,0 +1,326 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isLegacyPageDataApiPath } from '../page-data-routes.mjs'; +import { isPageDataPublicPath } from '../page-data-public-service.mjs'; +import { createPortalApiAuthMiddleware } from './portal-api-auth-middleware.mjs'; +import { + PORTAL_ACCESS_POLICY_MODE, + resolvePortalAccessEnforcementConfig, +} from './portal-access-policy.mjs'; + +function createResponseRecorder() { + return { + statusCode: 200, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +async function invoke(middleware, overrides = {}) { + const req = { + path: '/mindspace/v1/pages', + method: 'GET', + requestId: 'req-test', + userToken: 'user-token', + get: () => '', + ...overrides, + }; + const res = createResponseRecorder(); + let nextCalls = 0; + await middleware(req, res, () => { + nextCalls += 1; + }); + return { req, res, nextCalls }; +} + +function createLogger() { + const entries = []; + return { + entries, + info(...args) { + entries.push(['info', ...args]); + }, + error(...args) { + entries.push(['error', ...args]); + }, + }; +} + +function createMiddleware(overrides = {}) { + return createPortalApiAuthMiddleware({ + isPageDataPublicPath, + isLegacyPageDataApiPath, + logger: createLogger(), + ...overrides, + }); +} + +test('direct global-auth bypass routes preserve unauthenticated access', async () => { + const middleware = createMiddleware(); + for (const [path, method] of [ + ['/status', 'GET'], + ['/runtime/status', 'GET'], + ['/config/blocked-words', 'GET'], + ['/internal/agent/jobs/job-1/claim', 'POST'], + ['/mindspace/v1/assets/asset-1/download', 'GET'], + ]) { + const result = await invoke(middleware, { path, method }); + assert.equal(result.nextCalls, 1, `${method} ${path}`); + assert.equal(result.res.body, null); + } +}); + +test('no-auth mode keeps private and adjacent routes protected', async () => { + const middleware = createMiddleware(); + for (const [path, method] of [ + ['/mindspace/v1/pages', 'GET'], + ['/public/pages/page-1/data/signups/rows', 'GET'], + ['/public/pages/page-1/data/signups', 'POST'], + ['/page-dataevil/signups', 'GET'], + ['/plaza/v1/posts/post-1/reports', 'POST'], + ]) { + const result = await invoke(middleware, { path, method }); + assert.equal(result.nextCalls, 0, `${method} ${path}`); + assert.equal(result.res.statusCode, 401); + } +}); + +test('legacy mode accepts only a verified legacy session', async () => { + const verifiedTokens = []; + const legacyAuth = { + verify(token) { + verifiedTokens.push(token); + return token === 'legacy-ok'; + }, + }; + const middleware = createMiddleware({ + getLegacyAuth: () => legacyAuth, + getLegacySessionToken: (req) => req.legacyToken, + }); + + const denied = await invoke(middleware, { legacyToken: 'legacy-bad' }); + assert.equal(denied.res.statusCode, 401); + const allowed = await invoke(middleware, { legacyToken: 'legacy-ok' }); + assert.equal(allowed.nextCalls, 1); + assert.deepEqual(verifiedTokens, ['legacy-bad', 'legacy-ok']); +}); + +test('multi-user public routes preserve optional user hydration', async () => { + let getMeCalls = 0; + const user = { id: 'user-1' }; + const middleware = createMiddleware({ + getUserAuth: () => ({ + async getMe(token) { + getMeCalls += 1; + assert.equal(token, 'user-token'); + return user; + }, + }), + getTkmindProxy: () => ({}), + }); + + const anonymous = await invoke(middleware, { + path: '/public/pages/page-1/data/signups', + userSession: null, + }); + assert.equal(anonymous.nextCalls, 1); + assert.equal(anonymous.req.currentUser, undefined); + + const authenticated = await invoke(middleware, { + path: '/public/pages/page-1/data/signups', + userSession: { id: 'session-1' }, + }); + assert.equal(authenticated.nextCalls, 1); + assert.equal(authenticated.req.currentUser, user); + assert.equal(getMeCalls, 1); +}); + +test('multi-user private routes preserve login, expiry, and double-check behavior', async () => { + let currentUser = { id: 'user-1' }; + let getMeCalls = 0; + const middleware = createMiddleware({ + getUserAuth: () => ({ + async getMe() { + getMeCalls += 1; + return currentUser; + }, + }), + getTkmindProxy: () => ({}), + }); + + const anonymous = await invoke(middleware, { userSession: null }); + assert.equal(anonymous.res.statusCode, 401); + assert.equal(anonymous.res.body.message, '未授权,请重新登录'); + + const authenticated = await invoke(middleware, { + userSession: { id: 'session-1' }, + }); + assert.equal(authenticated.nextCalls, 1); + assert.equal(authenticated.req.currentUser, currentUser); + assert.equal(getMeCalls, 2); + + currentUser = null; + getMeCalls = 0; + const expired = await invoke(middleware, { + userSession: { id: 'session-expired' }, + }); + assert.equal(expired.res.statusCode, 401); + assert.equal(expired.res.body.message, '登录已过期'); + assert.equal(getMeCalls, 2); +}); + +test('multi-user session service errors fail closed only for private routes', async () => { + const middleware = createMiddleware({ + getUserAuth: () => ({ getMe: async () => ({ id: 'user-1' }) }), + getTkmindProxy: () => ({}), + }); + + const privateResult = await invoke(middleware, { + userSessionError: new Error('session unavailable'), + }); + assert.equal(privateResult.res.statusCode, 503); + + const publicResult = await invoke(middleware, { + path: '/public/pages/page-1/data/signups', + userSessionError: new Error('session unavailable'), + }); + assert.equal(publicResult.nextCalls, 1); +}); + +test('multi-user getMe failures preserve the 503 response and error log', async () => { + const logger = createLogger(); + const middleware = createMiddleware({ + getUserAuth: () => ({ + async getMe() { + throw new Error('database timeout'); + }, + }), + getTkmindProxy: () => ({}), + logger, + }); + const result = await invoke(middleware, { + userSession: { id: 'session-1' }, + }); + assert.equal(result.res.statusCode, 503); + assert.equal(result.res.body.message, '用户认证服务不可用,请稍后重试'); + assert.deepEqual(logger.entries[0], [ + 'error', + '[Auth] API auth failed:', + 'database timeout', + ]); +}); + +test('shadow mode records differences without changing the response', async () => { + const events = []; + const middleware = createMiddleware({ + accessPolicyMode: PORTAL_ACCESS_POLICY_MODE.SHADOW, + accessShadowReporter: { + record(event) { + events.push(event); + }, + }, + }); + const result = await invoke(middleware, { + path: '/plaza/v1/categories', + }); + assert.equal(result.res.statusCode, 401); + assert.equal(events.length, 1); + assert.equal(events[0].policyGroup, 'plaza-optional-user'); + assert.equal(events[0].mismatch, true); +}); + +test('single-group enforcement overrides only the selected no-auth boundary', async () => { + const logger = createLogger(); + const middleware = createMiddleware({ + accessEnforcementConfig: resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1', + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: 'page-data-public', + }), + logger, + }); + + const selected = await invoke(middleware, { + path: '/public/pages/page-1/data/signups', + }); + assert.equal(selected.nextCalls, 1); + assert.equal(logger.entries.length, 1); + assert.match(logger.entries[0][2], /"behaviorChanged":true/); + + const legacy = await invoke(middleware, { + path: '/page-data/signups', + }); + assert.equal(legacy.res.statusCode, 401); + const plaza = await invoke(middleware, { + path: '/plaza/v1/categories', + }); + assert.equal(plaza.res.statusCode, 401); +}); + +test('enforcement does not skip multi-user optional identity hydration', async () => { + let getMeCalls = 0; + const middleware = createMiddleware({ + getUserAuth: () => ({ + async getMe() { + getMeCalls += 1; + return { id: 'user-1' }; + }, + }), + getTkmindProxy: () => ({}), + accessEnforcementConfig: resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1', + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: 'page-data-public', + }), + }); + const result = await invoke(middleware, { + path: '/public/pages/page-1/data/signups', + userSession: { id: 'session-1' }, + }); + assert.equal(result.nextCalls, 1); + assert.equal(result.req.currentUser.id, 'user-1'); + assert.equal(getMeCalls, 1); +}); + +test('kill switch restores the legacy global-auth result', async () => { + const middleware = createMiddleware({ + accessEnforcementConfig: resolvePortalAccessEnforcementConfig({ + MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1', + MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: 'page-data-public', + MEMIND_PORTAL_ACCESS_POLICY_KILL_SWITCH: '1', + }), + }); + const result = await invoke(middleware, { + path: '/public/pages/page-1/data/signups', + }); + assert.equal(result.res.statusCode, 401); + assert.equal(result.nextCalls, 0); +}); + +test('middleware waits for auth bootstrap before reading service getters', async () => { + const calls = []; + const middleware = createMiddleware({ + waitForUserAuthReady: async () => { + calls.push('ready'); + }, + getUserAuth: () => { + calls.push('user-auth'); + return null; + }, + getTkmindProxy: () => { + calls.push('proxy'); + return null; + }, + getLegacyAuth: () => { + calls.push('legacy'); + return null; + }, + }); + await invoke(middleware); + assert.deepEqual(calls, ['ready', 'user-auth', 'proxy', 'legacy']); +}); diff --git a/server/portal-auth-services-bootstrap.mjs b/server/portal-auth-services-bootstrap.mjs new file mode 100644 index 0000000..809fb5a --- /dev/null +++ b/server/portal-auth-services-bootstrap.mjs @@ -0,0 +1,169 @@ +import { createRechargeService } from '../billing-recharge.mjs'; +import { + createPlanCatalogService, + createSubscriptionService, + ensurePlanCatalogSchema, +} from '../billing-subscription.mjs'; +import { resolveNormalizedRouterDecisionMode } from '../chat-intent-router.mjs'; +import { + createSessionAccess, + isSessionBrokerEnabled, +} from '../session-broker.mjs'; +import { isSessionBrokerMetricsEnabled } from '../session-broker-metrics.mjs'; +import { isSessionStreamReplayEnabled } from '../session-stream.mjs'; +import { createUserAuth } from '../user-auth.mjs'; +import { createUserDataSpaceService } from '../user-data-space-service.mjs'; +import { + createWechatOAuthService, + loadWechatOAuthConfig, +} from '../wechat-oauth.mjs'; +import { + createWechatPayClient, + loadWechatPayConfig, +} from '../wechat-pay.mjs'; + +function isEnabledFlag(value) { + return ['1', 'true', 'yes', 'on'].includes( + String(value ?? '').trim().toLowerCase(), + ); +} + +export async function bootstrapPortalAuthServices({ + pool, + h5Root, + usersRoot, + mindSearchConfigService, + env = process.env, + logger = console, + ensurePlanCatalogSchemaFn = ensurePlanCatalogSchema, + createPlanCatalogServiceFn = createPlanCatalogService, + createSubscriptionServiceFn = createSubscriptionService, + createUserAuthFn = createUserAuth, + createUserDataSpaceServiceFn = createUserDataSpaceService, + createSessionAccessFn = createSessionAccess, + isSessionBrokerEnabledFn = isSessionBrokerEnabled, + isSessionBrokerMetricsEnabledFn = + isSessionBrokerMetricsEnabled, + resolveNormalizedRouterDecisionModeFn = + resolveNormalizedRouterDecisionMode, + isSessionStreamReplayEnabledFn = + isSessionStreamReplayEnabled, + loadWechatPayConfigFn = loadWechatPayConfig, + createWechatPayClientFn = createWechatPayClient, + loadWechatOAuthConfigFn = loadWechatOAuthConfig, + createWechatOAuthServiceFn = createWechatOAuthService, + createRechargeServiceFn = createRechargeService, +} = {}) { + if ( + !pool || + !h5Root || + !usersRoot || + typeof mindSearchConfigService?.getEffectiveConfig !== + 'function' + ) { + throw new Error( + 'bootstrapPortalAuthServices requires pool, roots, and MindSearch config', + ); + } + + await ensurePlanCatalogSchemaFn(pool); + const planCatalogService = + createPlanCatalogServiceFn(pool); + const subscriptionService = + createSubscriptionServiceFn(pool, { + getPlanAsync: (planType) => + planCatalogService.getPlan(planType), + }); + subscriptionService._planCatalogService = + planCatalogService; + + const userAuth = createUserAuthFn(pool, { + usersRoot, + h5Root, + defaultSignupBalanceCents: Number( + env.H5_SIGNUP_BALANCE_CENTS ?? 500, + ), + subscriptionService, + getMindSearchConfig: () => + mindSearchConfigService.getEffectiveConfig(), + provisionUserDataSpace: async ({ + userId, + workspaceRoot, + }) => { + const service = createUserDataSpaceServiceFn({ + workspaceRoot, + userId, + query: pool, + }); + return service.ensureReady(); + }, + }); + + const sessionAccess = createSessionAccessFn({ + userAuth, + enabled: isSessionBrokerEnabledFn(), + }); + if (sessionAccess.enabled) { + logger.log( + '[Portal] Session Broker enabled (MEMIND_SESSION_BROKER_ENABLED=1)', + ); + } + + const routerDecisionMode = + resolveNormalizedRouterDecisionModeFn(env); + const h5SessionFlags = [ + sessionAccess.enabled && + 'MEMIND_SESSION_BROKER_ENABLED', + isSessionBrokerMetricsEnabledFn() && + 'MEMIND_SESSION_BROKER_METRICS', + routerDecisionMode !== 'off' && + `MEMIND_ROUTER_NORMALIZED_DECISION=${routerDecisionMode}`, + isSessionStreamReplayEnabledFn() && + 'MEMIND_SESSION_STREAM_REPLAY', + isEnabledFlag(env.MEMIND_SSE_EVENT_TAXONOMY) && + 'MEMIND_SSE_EVENT_TAXONOMY', + isEnabledFlag(env.MEMIND_RUN_STREAM_REPLAY) && + 'MEMIND_RUN_STREAM_REPLAY', + isEnabledFlag(env.MEMIND_H5_HTML_FINISH_GUARD) && + 'MEMIND_H5_HTML_FINISH_GUARD', + ].filter(Boolean); + if (h5SessionFlags.length) { + logger.log( + `[Portal] H5 session flags: ${h5SessionFlags.join(', ')}`, + ); + } + + const wechatPayClient = createWechatPayClientFn( + loadWechatPayConfigFn(), + ); + const wechatOAuthService = + createWechatOAuthServiceFn( + pool, + loadWechatOAuthConfigFn(), + { userAuth }, + ); + const rechargeService = createRechargeServiceFn(pool, { + userAuth, + wechatPay: wechatPayClient, + }); + if (wechatPayClient.enabled) { + logger.log( + `WeChat Pay recharge enabled (${wechatPayClient.apiVersion ?? 'unknown'})`, + ); + } + if (wechatOAuthService.enabled) { + logger.log('WeChat OAuth login enabled'); + } + + return { + planCatalogService, + subscriptionService, + userAuth, + sessionAccess, + routerDecisionMode, + h5SessionFlags, + wechatPayClient, + wechatOAuthService, + rechargeService, + }; +} diff --git a/server/portal-auth-services-bootstrap.test.mjs b/server/portal-auth-services-bootstrap.test.mjs new file mode 100644 index 0000000..e55e0eb --- /dev/null +++ b/server/portal-auth-services-bootstrap.test.mjs @@ -0,0 +1,317 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { bootstrapPortalAuthServices } from './portal-auth-services-bootstrap.mjs'; + +function createSetup(overrides = {}) { + const calls = []; + const pool = { id: 'pool' }; + const planCatalogService = { + getPlan(planType) { + calls.push(['get-plan', planType]); + return { planType }; + }, + }; + const userAuth = { id: 'user-auth' }; + const sessionAccess = { + enabled: true, + id: 'session-access', + }; + const wechatPayClient = { + enabled: true, + apiVersion: 'v3', + }; + const wechatOAuthService = { enabled: true }; + const rechargeService = { id: 'recharge' }; + let subscriptionOptions; + let userAuthOptions; + let sessionAccessOptions; + let oauthArgs; + let rechargeArgs; + let userDataSpaceOptions; + + const options = { + pool, + h5Root: '/app', + usersRoot: '/users', + env: { + H5_SIGNUP_BALANCE_CENTS: '750', + }, + mindSearchConfigService: { + getEffectiveConfig() { + calls.push(['mind-search-config']); + return { enabled: true }; + }, + }, + logger: { + log(message) { + calls.push(['log', message]); + }, + }, + async ensurePlanCatalogSchemaFn(receivedPool) { + assert.equal(receivedPool, pool); + calls.push(['plan-schema']); + }, + createPlanCatalogServiceFn(receivedPool) { + assert.equal(receivedPool, pool); + calls.push(['plan-service']); + return planCatalogService; + }, + createSubscriptionServiceFn( + receivedPool, + receivedOptions, + ) { + assert.equal(receivedPool, pool); + calls.push(['subscription-service']); + subscriptionOptions = receivedOptions; + return { id: 'subscription' }; + }, + createUserAuthFn(receivedPool, receivedOptions) { + assert.equal(receivedPool, pool); + calls.push(['user-auth']); + userAuthOptions = receivedOptions; + return userAuth; + }, + createUserDataSpaceServiceFn(receivedOptions) { + userDataSpaceOptions = receivedOptions; + return { + ensureReady() { + calls.push(['user-space-ready']); + return { ready: true }; + }, + }; + }, + createSessionAccessFn(receivedOptions) { + calls.push(['session-access']); + sessionAccessOptions = receivedOptions; + return sessionAccess; + }, + isSessionBrokerEnabledFn() { + return true; + }, + isSessionBrokerMetricsEnabledFn() { + return false; + }, + resolveNormalizedRouterDecisionModeFn(receivedEnv) { + assert.equal(receivedEnv, options.env); + return 'off'; + }, + isSessionStreamReplayEnabledFn() { + return false; + }, + loadWechatPayConfigFn() { + calls.push(['pay-config']); + return { merchantId: 'merchant' }; + }, + createWechatPayClientFn(config) { + calls.push(['pay-client', config]); + return wechatPayClient; + }, + loadWechatOAuthConfigFn() { + calls.push(['oauth-config']); + return { appId: 'app-id' }; + }, + createWechatOAuthServiceFn(...args) { + calls.push(['oauth-service']); + oauthArgs = args; + return wechatOAuthService; + }, + createRechargeServiceFn(...args) { + calls.push(['recharge-service']); + rechargeArgs = args; + return rechargeService; + }, + ...overrides, + }; + + return { + calls, + options, + pool, + planCatalogService, + userAuth, + sessionAccess, + wechatPayClient, + wechatOAuthService, + rechargeService, + getCaptured() { + return { + subscriptionOptions, + userAuthOptions, + sessionAccessOptions, + oauthArgs, + rechargeArgs, + userDataSpaceOptions, + }; + }, + }; +} + +test('requires the auth service bootstrap inputs', async () => { + await assert.rejects( + bootstrapPortalAuthServices(), + /requires pool, roots, and MindSearch config/, + ); +}); + +test('preserves subscription, auth, and user-space wiring', async () => { + const setup = createSetup(); + const result = await bootstrapPortalAuthServices( + setup.options, + ); + let captured = setup.getCaptured(); + + assert.deepEqual( + setup.calls.slice(0, 4).map(([name]) => name), + [ + 'plan-schema', + 'plan-service', + 'subscription-service', + 'user-auth', + ], + ); + assert.equal( + result.subscriptionService._planCatalogService, + setup.planCatalogService, + ); + assert.deepEqual( + await captured.subscriptionOptions.getPlanAsync( + 'pro', + ), + { planType: 'pro' }, + ); + assert.equal(captured.userAuthOptions.usersRoot, '/users'); + assert.equal(captured.userAuthOptions.h5Root, '/app'); + assert.equal( + captured.userAuthOptions.defaultSignupBalanceCents, + 750, + ); + assert.equal( + captured.userAuthOptions.subscriptionService, + result.subscriptionService, + ); + assert.deepEqual( + captured.userAuthOptions.getMindSearchConfig(), + { enabled: true }, + ); + assert.deepEqual( + await captured.userAuthOptions.provisionUserDataSpace({ + userId: 'user-1', + workspaceRoot: '/workspace/user-1', + }), + { ready: true }, + ); + captured = setup.getCaptured(); + assert.deepEqual(captured.userDataSpaceOptions, { + workspaceRoot: '/workspace/user-1', + userId: 'user-1', + query: setup.pool, + }); + assert.deepEqual(captured.sessionAccessOptions, { + userAuth: setup.userAuth, + enabled: true, + }); +}); + +test('reports every enabled H5 session flag', async () => { + const setup = createSetup({ + env: { + MEMIND_SSE_EVENT_TAXONOMY: ' TRUE ', + MEMIND_RUN_STREAM_REPLAY: 'yes', + MEMIND_H5_HTML_FINISH_GUARD: 'on', + }, + isSessionBrokerMetricsEnabledFn: () => true, + resolveNormalizedRouterDecisionModeFn: () => + 'shadow', + isSessionStreamReplayEnabledFn: () => true, + }); + + const result = await bootstrapPortalAuthServices( + setup.options, + ); + + assert.deepEqual(result.h5SessionFlags, [ + 'MEMIND_SESSION_BROKER_ENABLED', + 'MEMIND_SESSION_BROKER_METRICS', + 'MEMIND_ROUTER_NORMALIZED_DECISION=shadow', + 'MEMIND_SESSION_STREAM_REPLAY', + 'MEMIND_SSE_EVENT_TAXONOMY', + 'MEMIND_RUN_STREAM_REPLAY', + 'MEMIND_H5_HTML_FINISH_GUARD', + ]); + assert.ok( + setup.calls.some( + ([kind, message]) => + kind === 'log' && + message === + `[Portal] H5 session flags: ${result.h5SessionFlags.join(', ')}`, + ), + ); +}); + +test('preserves WeChat and recharge service wiring and logs', async () => { + const setup = createSetup(); + const result = await bootstrapPortalAuthServices( + setup.options, + ); + const captured = setup.getCaptured(); + + assert.deepEqual(captured.oauthArgs, [ + setup.pool, + { appId: 'app-id' }, + { userAuth: setup.userAuth }, + ]); + assert.deepEqual(captured.rechargeArgs, [ + setup.pool, + { + userAuth: setup.userAuth, + wechatPay: setup.wechatPayClient, + }, + ]); + assert.equal( + result.wechatOAuthService, + setup.wechatOAuthService, + ); + assert.equal(result.rechargeService, setup.rechargeService); + assert.ok( + setup.calls.some( + ([kind, message]) => + kind === 'log' && + message === 'WeChat Pay recharge enabled (v3)', + ), + ); + assert.ok( + setup.calls.some( + ([kind, message]) => + kind === 'log' && + message === 'WeChat OAuth login enabled', + ), + ); +}); + +test('keeps disabled integrations quiet and uses the balance default', async () => { + const setup = createSetup({ + env: {}, + createSessionAccessFn: () => ({ enabled: false }), + isSessionBrokerEnabledFn: () => false, + createWechatPayClientFn: () => ({ enabled: false }), + createWechatOAuthServiceFn: () => ({ + enabled: false, + }), + }); + + const result = await bootstrapPortalAuthServices( + setup.options, + ); + const captured = setup.getCaptured(); + + assert.equal( + captured.userAuthOptions.defaultSignupBalanceCents, + 500, + ); + assert.deepEqual(result.h5SessionFlags, []); + assert.deepEqual( + setup.calls.filter(([kind]) => kind === 'log'), + [], + ); +} +); diff --git a/server/portal-auth-session-helpers.mjs b/server/portal-auth-session-helpers.mjs new file mode 100644 index 0000000..6a53e9b --- /dev/null +++ b/server/portal-auth-session-helpers.mjs @@ -0,0 +1,82 @@ +import { + AUTH_COOKIE, + clearSessionCookie, + parseCookies, +} from '../auth.mjs'; +import { + clearUserLogoutCookies, + resolveCookieDomainForRequest, + USER_COOKIE, + userLoginCookies, +} from '../user-auth.mjs'; + +export function createPortalAuthSessionHelpers({ + isSecureRequest, + getLegacyAuth = () => null, + getUserAuth = () => null, + logger = console, +} = {}) { + if (typeof isSecureRequest !== 'function') { + throw new Error( + 'createPortalAuthSessionHelpers requires isSecureRequest', + ); + } + + function legacySessionToken(req) { + return parseCookies(req.get('cookie'))[AUTH_COOKIE]; + } + + function userToken(req) { + return parseCookies(req.get('cookie'))[USER_COOKIE]; + } + + function setUserLoginCookies(res, req, token) { + res.set( + 'Set-Cookie', + userLoginCookies( + token, + isSecureRequest(req), + resolveCookieDomainForRequest(req), + ), + ); + } + + function clearUserLoginCookies(res, req) { + const secure = isSecureRequest(req); + const domain = resolveCookieDomainForRequest(req); + const cookies = clearUserLogoutCookies(secure, domain); + if (getLegacyAuth()) { + cookies.push(clearSessionCookie(secure)); + } + res.set('Set-Cookie', cookies); + } + + async function attachUserSession(req, _res, next) { + const userAuth = getUserAuth(); + if (!userAuth) return next(); + const token = userToken(req); + req.userToken = token; + try { + req.userSession = token + ? await userAuth.verify(token) + : null; + req.userSessionError = null; + } catch (error) { + req.userSession = null; + req.userSessionError = error; + logger.error( + '[Auth] session verify failed:', + error instanceof Error ? error.message : error, + ); + } + return next(); + } + + return { + legacySessionToken, + userToken, + setUserLoginCookies, + clearUserLoginCookies, + attachUserSession, + }; +} diff --git a/server/portal-auth-session-helpers.test.mjs b/server/portal-auth-session-helpers.test.mjs new file mode 100644 index 0000000..37deaad --- /dev/null +++ b/server/portal-auth-session-helpers.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createPortalAuthSessionHelpers } from './portal-auth-session-helpers.mjs'; + +function createRequest(cookie = '', overrides = {}) { + return { + headers: {}, + hostname: 'm.example.com', + protocol: 'https', + get(name) { + if (String(name).toLowerCase() === 'cookie') return cookie; + if (String(name).toLowerCase() === 'host') { + return this.headers.host ?? this.hostname; + } + return this.headers[String(name).toLowerCase()]; + }, + ...overrides, + }; +} + +function createResponse() { + return { + headers: {}, + set(name, value) { + this.headers[name] = value; + return this; + }, + }; +} + +test('auth session helpers parse legacy and user cookies', () => { + const helpers = createPortalAuthSessionHelpers({ + isSecureRequest: () => false, + }); + const req = createRequest( + 'tkmind_h5_session=legacy-token; tkmind_user_session=user-token', + ); + assert.equal(helpers.legacySessionToken(req), 'legacy-token'); + assert.equal(helpers.userToken(req), 'user-token'); +}); + +test('auth session helpers set and clear user login cookies', () => { + const helpers = createPortalAuthSessionHelpers({ + isSecureRequest: () => true, + getLegacyAuth: () => ({}), + }); + const req = createRequest('', { + headers: { host: 'm.tkmind.cn' }, + }); + const loginRes = createResponse(); + helpers.setUserLoginCookies(loginRes, req, 'user-token'); + const loginCookies = loginRes.headers['Set-Cookie']; + assert.ok(Array.isArray(loginCookies)); + assert.ok( + loginCookies.some((cookie) => + cookie.includes('tkmind_user_session=user-token'), + ), + ); + assert.ok( + loginCookies.every((cookie) => cookie.includes('Secure')), + ); + + const logoutRes = createResponse(); + helpers.clearUserLoginCookies(logoutRes, req); + const logoutCookies = logoutRes.headers['Set-Cookie']; + assert.ok( + logoutCookies.some((cookie) => + cookie.includes('tkmind_user_session='), + ), + ); + assert.ok( + logoutCookies.some((cookie) => + cookie.includes('tkmind_h5_session='), + ), + ); +}); + +test('attachUserSession verifies the user token and populates request state', async () => { + const calls = []; + const helpers = createPortalAuthSessionHelpers({ + isSecureRequest: () => false, + getUserAuth: () => ({ + async verify(token) { + calls.push(token); + return { userId: 'user-1' }; + }, + }), + }); + const req = createRequest( + 'tkmind_user_session=user-token', + ); + let nextCalls = 0; + await helpers.attachUserSession(req, {}, () => { + nextCalls += 1; + }); + assert.deepEqual(calls, ['user-token']); + assert.deepEqual(req.userSession, { userId: 'user-1' }); + assert.equal(req.userSessionError, null); + assert.equal(nextCalls, 1); +}); + +test('attachUserSession records verification failure and always continues', async () => { + const logs = []; + const failure = new Error('database unavailable'); + const helpers = createPortalAuthSessionHelpers({ + isSecureRequest: () => false, + getUserAuth: () => ({ + async verify() { + throw failure; + }, + }), + logger: { + error(...args) { + logs.push(args); + }, + }, + }); + const req = createRequest( + 'tkmind_user_session=user-token', + ); + let nextCalls = 0; + await helpers.attachUserSession(req, {}, () => { + nextCalls += 1; + }); + assert.equal(req.userSession, null); + assert.equal(req.userSessionError, failure); + assert.equal(nextCalls, 1); + assert.deepEqual(logs[0], [ + '[Auth] session verify failed:', + 'database unavailable', + ]); + + const noAuthHelpers = createPortalAuthSessionHelpers({ + isSecureRequest: () => false, + }); + const noAuthReq = createRequest(); + await noAuthHelpers.attachUserSession(noAuthReq, {}, () => { + nextCalls += 1; + }); + assert.equal(nextCalls, 2); + assert.equal(noAuthReq.userToken, undefined); +}); diff --git a/server/portal-billing-routes.mjs b/server/portal-billing-routes.mjs new file mode 100644 index 0000000..be5231b --- /dev/null +++ b/server/portal-billing-routes.mjs @@ -0,0 +1,470 @@ +import { PLAN_CATALOG } from '../billing-subscription.mjs'; + +export function attachPortalBillingRoutes({ + app, + jsonBody, + userAuthReady = Promise.resolve(), + getUserAuth = () => null, + getRechargeService = () => null, + getSubscriptionService = () => null, + getMindSpace = () => null, + userToken, + planCatalogFallback = PLAN_CATALOG, +} = {}) { + if ( + !app || + typeof jsonBody !== 'function' || + typeof userToken !== 'function' + ) { + throw new Error( + 'attachPortalBillingRoutes requires route dependencies', + ); + } + + app.get( + '/auth/billing/ledger', + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + if (!userAuth) { + return res.status(503).json({ + message: '未启用用户系统', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const limit = Math.min( + Math.max( + Number(req.query?.limit) || 30, + 1, + ), + 100, + ); + const entries = + await userAuth.listBillingLedger({ + userId: me.id, + limit, + types: [ + 'recharge', + 'adjust', + 'refund', + ], + }); + res.json({ entries }); + }, + ); + + app.get( + '/auth/billing/config', + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const rechargeService = + getRechargeService(); + if (!userAuth || !rechargeService) { + return res.status(503).json({ + message: '未启用计费系统', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const subscriptionService = + getSubscriptionService(); + const [config, sub] = await Promise.all([ + rechargeService.getBillingConfig(me.id), + subscriptionService + ? subscriptionService.getActiveSubscription( + me.id, + ) + : null, + ]); + return res.json({ + ...config, + subscription: sub, + }); + }, + ); + + app.get( + '/auth/billing/subscription', + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + if (!userAuth) { + return res.status(503).json({ + message: '未启用用户系统', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const subscriptionService = + getSubscriptionService(); + const sub = subscriptionService + ? await subscriptionService.getActiveSubscription( + me.id, + ) + : null; + const planCatalog = + subscriptionService?._planCatalogService; + const plans = + !sub && planCatalog + ? await planCatalog.listPlans({ + includeInactive: false, + }) + : sub + ? undefined + : planCatalogFallback; + return res.json({ + subscription: sub, + plans, + }); + }, + ); + + app.get( + '/auth/billing/plans', + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + if (!userAuth) { + return res.status(503).json({ + message: '未启用用户系统', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const subscriptionService = + getSubscriptionService(); + const planCatalog = + subscriptionService?._planCatalogService; + const plans = planCatalog + ? ( + await planCatalog.listPlans({ + includeInactive: false, + }) + ) + .filter((plan) => plan.priceCents > 0) + .map((plan) => ({ + key: plan.planType, + ...plan, + })) + : Object.entries(planCatalogFallback) + .filter( + ([, plan]) => plan.priceCents > 0, + ) + .map(([key, plan]) => ({ + key, + ...plan, + })); + const [sub, wallet] = await Promise.all([ + subscriptionService + ? subscriptionService.getActiveSubscription( + me.id, + ) + : null, + userAuth.getUserById(me.id), + ]); + return res.json({ + plans, + subscription: sub, + balanceCents: wallet + ? Number(wallet.balance_cents ?? 0) + : 0, + }); + }, + ); + + app.post( + '/auth/billing/subscribe', + jsonBody, + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const subscriptionService = + getSubscriptionService(); + if (!userAuth || !subscriptionService) { + return res.status(503).json({ + message: '未启用订阅系统', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + if (me.status === 'disabled') { + return res.status(403).json({ + message: '账户已禁用', + }); + } + const { + planType, + autoRenew = false, + } = req.body ?? {}; + if ( + !planType || + typeof planType !== 'string' + ) { + return res.status(400).json({ + message: '请选择套餐', + }); + } + const result = + await subscriptionService.purchaseSubscription( + me.id, + planType, + Boolean(autoRenew), + ); + if (!result.ok) { + if ( + result.code === + 'INSUFFICIENT_BALANCE' + ) { + return res.status(402).json({ + message: result.message, + code: result.code, + balanceCents: result.balanceCents, + requiredCents: result.requiredCents, + shortfallCents: + result.shortfallCents, + }); + } + if ( + result.code === + 'DOWNGRADE_NOT_ALLOWED' + ) { + return res.status(409).json({ + message: result.message, + code: result.code, + currentPlanType: + result.currentPlanType, + }); + } + return res.status(400).json({ + message: result.message, + }); + } + return res.json({ + subscription: result.subscription, + balanceCents: result.balanceCents, + }); + }, + ); + + app.post( + '/auth/billing/space-purchase', + jsonBody, + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const mindSpace = getMindSpace(); + if (!userAuth || !mindSpace) { + return res.status(503).json({ + message: '未启用空间系统', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const sizeMb = Number(req.body?.sizeMb); + const result = + await userAuth.purchaseSpaceQuota( + me.id, + sizeMb, + ); + if (!result.ok) { + if ( + result.code === + 'INSUFFICIENT_BALANCE' + ) { + return res.status(402).json({ + message: result.message, + code: result.code, + details: { + code: 'INSUFFICIENT_BALANCE', + balanceCents: + result.balanceCents, + minRechargeCents: + result.minRechargeCents, + suggestedTiers: + result.suggestedTiers, + }, + }); + } + return res.status(400).json({ + message: result.message, + }); + } + const quota = await mindSpace.getQuota(me.id); + return res.json({ + quota: quota ?? result.quota, + balanceCents: result.balanceCents, + purchasedMb: sizeMb, + costCents: sizeMb * 200, + }); + }, + ); + + app.post( + '/auth/billing/auto-renew', + jsonBody, + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const subscriptionService = + getSubscriptionService(); + if (!userAuth || !subscriptionService) { + return res.status(503).json({ + message: '未启用订阅系统', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const { enabled } = req.body ?? {}; + if (typeof enabled !== 'boolean') { + return res.status(400).json({ + message: '请传入 enabled: true/false', + }); + } + const result = + await subscriptionService.setAutoRenew( + me.id, + enabled, + ); + return res.json(result); + }, + ); + + app.post( + '/auth/billing/recharge-orders', + jsonBody, + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const rechargeService = + getRechargeService(); + if (!userAuth || !rechargeService) { + return res.status(503).json({ + message: '未启用计费系统', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const amountCents = Number( + req.body?.amountCents, + ); + const payScene = [ + 'native', + 'h5', + 'jsapi', + ].includes(req.body?.payScene) + ? req.body.payScene + : 'native'; + const result = + await rechargeService.createOrder({ + userId: me.id, + amountCents, + payScene, + clientIp: req.ip, + }); + if (!result.ok) { + return res.status(400).json({ + message: result.message, + }); + } + return res.status(201).json({ + order: result.order, + }); + }, + ); + + app.get( + '/auth/billing/recharge-orders/:orderId', + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const rechargeService = + getRechargeService(); + if (!userAuth || !rechargeService) { + return res.status(503).json({ + message: '未启用计费系统', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const order = + await rechargeService.getOrderForUser( + me.id, + req.params.orderId, + ); + if (!order) { + return res.status(404).json({ + message: '订单不存在', + }); + } + let balanceCents = null; + if (order.status === 'paid') { + const user = await userAuth.getUserById( + me.id, + ); + balanceCents = user + ? Number(user.balance_cents ?? 0) + : null; + } + return res.json({ + order, + balanceCents, + }); + }, + ); +} diff --git a/server/portal-billing-routes.test.mjs b/server/portal-billing-routes.test.mjs new file mode 100644 index 0000000..042b1e5 --- /dev/null +++ b/server/portal-billing-routes.test.mjs @@ -0,0 +1,622 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + attachPortalBillingRoutes, +} from './portal-billing-routes.mjs'; + +function createResponse() { + return { + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +function createSetup(overrides = {}) { + const routes = []; + const calls = []; + let userAuth = { + async getMe(token) { + calls.push(['get-me', token]); + return { + id: 'user-1', + status: 'active', + }; + }, + async listBillingLedger(options) { + calls.push(['ledger', options]); + return [{ id: 'entry-1' }]; + }, + async getUserById(userId) { + calls.push(['get-user', userId]); + return { + id: userId, + balance_cents: 8800, + }; + }, + async purchaseSpaceQuota(userId, sizeMb) { + calls.push([ + 'space-purchase', + userId, + sizeMb, + ]); + return { + ok: true, + quota: { + quotaBytes: 1024, + }, + balanceCents: 5000, + }; + }, + }; + let rechargeService = { + async getBillingConfig(userId) { + calls.push(['billing-config', userId]); + return { + minRechargeCents: 1000, + }; + }, + async createOrder(options) { + calls.push(['create-order', options]); + return { + ok: true, + order: { + id: 'order-1', + status: 'pending', + }, + }; + }, + async getOrderForUser(userId, orderId) { + calls.push([ + 'get-order', + userId, + orderId, + ]); + return { + id: orderId, + status: 'paid', + }; + }, + }; + let subscriptionService = { + async getActiveSubscription(userId) { + calls.push(['subscription', userId]); + return { + planType: 'pro', + }; + }, + async purchaseSubscription( + userId, + planType, + autoRenew, + ) { + calls.push([ + 'subscribe', + userId, + planType, + autoRenew, + ]); + return { + ok: true, + subscription: { + planType, + autoRenew, + }, + balanceCents: 6800, + }; + }, + async setAutoRenew(userId, enabled) { + calls.push([ + 'auto-renew', + userId, + enabled, + ]); + return { + ok: true, + enabled, + }; + }, + }; + let mindSpace = { + async getQuota(userId) { + calls.push(['quota', userId]); + return { + quotaBytes: 2048, + }; + }, + }; + const jsonBody = (_req, _res, next) => next(); + const app = { + get(path, ...handlers) { + routes.push({ + method: 'get', + path, + handlers, + }); + }, + post(path, ...handlers) { + routes.push({ + method: 'post', + path, + handlers, + }); + }, + }; + const options = { + app, + jsonBody, + userAuthReady: Promise.resolve(), + getUserAuth: () => userAuth, + getRechargeService: () => rechargeService, + getSubscriptionService: () => + subscriptionService, + getMindSpace: () => mindSpace, + userToken: () => 'request-token', + planCatalogFallback: { + free: { + planType: 'free', + priceCents: 0, + }, + pro: { + planType: 'pro', + priceCents: 5000, + }, + }, + ...overrides, + }; + attachPortalBillingRoutes(options); + + return { + routes, + calls, + jsonBody, + setUserAuth(value) { + userAuth = value; + }, + setRechargeService(value) { + rechargeService = value; + }, + setSubscriptionService(value) { + subscriptionService = value; + }, + setMindSpace(value) { + mindSpace = value; + }, + route(method, path) { + return routes.find( + (route) => + route.method === method && + route.path === path, + ); + }, + }; +} + +async function invoke( + setup, + method, + path, + req = {}, +) { + const route = setup.route(method, path); + assert.ok( + route, + `${method.toUpperCase()} ${path}`, + ); + const res = createResponse(); + await route.handlers.at(-1)( + { + body: {}, + query: {}, + params: {}, + ip: '127.0.0.1', + ...req, + }, + res, + ); + return res; +} + +test('registers the billing route inventory and JSON middleware order', () => { + const setup = createSetup(); + assert.deepEqual( + setup.routes.map(({ method, path }) => [ + method, + path, + ]), + [ + ['get', '/auth/billing/ledger'], + ['get', '/auth/billing/config'], + ['get', '/auth/billing/subscription'], + ['get', '/auth/billing/plans'], + ['post', '/auth/billing/subscribe'], + ['post', '/auth/billing/space-purchase'], + ['post', '/auth/billing/auto-renew'], + ['post', '/auth/billing/recharge-orders'], + [ + 'get', + '/auth/billing/recharge-orders/:orderId', + ], + ], + ); + for (const route of setup.routes.filter( + ({ method }) => method === 'post', + )) { + assert.equal(route.handlers[0], setup.jsonBody); + } +}); + +test('preserves billing ledger scope and billing configuration', async () => { + const setup = createSetup(); + const ledger = await invoke( + setup, + 'get', + '/auth/billing/ledger', + { + query: { limit: '500' }, + }, + ); + assert.deepEqual(ledger.body, { + entries: [{ id: 'entry-1' }], + }); + assert.ok( + setup.calls.some( + (call) => + call[0] === 'ledger' && + call[1].limit === 100 && + call[1].types.join(',') === + 'recharge,adjust,refund', + ), + ); + + const config = await invoke( + setup, + 'get', + '/auth/billing/config', + ); + assert.deepEqual(config.body, { + minRechargeCents: 1000, + subscription: { + planType: 'pro', + }, + }); +}); + +test('preserves subscription detail and plan catalog projections', async () => { + const setup = createSetup(); + setup.setSubscriptionService(null); + const subscription = await invoke( + setup, + 'get', + '/auth/billing/subscription', + ); + assert.deepEqual(subscription.body, { + subscription: null, + plans: { + free: { + planType: 'free', + priceCents: 0, + }, + pro: { + planType: 'pro', + priceCents: 5000, + }, + }, + }); + + const plans = await invoke( + setup, + 'get', + '/auth/billing/plans', + ); + assert.deepEqual(plans.body, { + plans: [ + { + key: 'pro', + planType: 'pro', + priceCents: 5000, + }, + ], + subscription: null, + balanceCents: 8800, + }); + + const dynamic = createSetup(); + dynamic.setSubscriptionService({ + _planCatalogService: { + async listPlans(options) { + dynamic.calls.push([ + 'catalog', + options, + ]); + return [ + { + planType: 'free', + priceCents: 0, + }, + { + planType: 'team', + priceCents: 12000, + }, + ]; + }, + }, + async getActiveSubscription() { + return null; + }, + }); + const dynamicPlans = await invoke( + dynamic, + 'get', + '/auth/billing/plans', + ); + assert.deepEqual(dynamicPlans.body.plans, [ + { + key: 'team', + planType: 'team', + priceCents: 12000, + }, + ]); +}); + +test('preserves subscription purchase validation and error mappings', async () => { + const missing = createSetup(); + const missingRes = await invoke( + missing, + 'post', + '/auth/billing/subscribe', + ); + assert.equal(missingRes.statusCode, 400); + + const limited = createSetup(); + limited.setSubscriptionService({ + async purchaseSubscription() { + return { + ok: false, + code: 'INSUFFICIENT_BALANCE', + message: '余额不足', + balanceCents: 100, + requiredCents: 5000, + shortfallCents: 4900, + }; + }, + }); + const limitedRes = await invoke( + limited, + 'post', + '/auth/billing/subscribe', + { + body: { + planType: 'pro', + autoRenew: true, + }, + }, + ); + assert.equal(limitedRes.statusCode, 402); + assert.equal(limitedRes.body.shortfallCents, 4900); + + const downgrade = createSetup(); + downgrade.setSubscriptionService({ + async purchaseSubscription() { + return { + ok: false, + code: 'DOWNGRADE_NOT_ALLOWED', + message: '不可降级', + currentPlanType: 'team', + }; + }, + }); + const downgradeRes = await invoke( + downgrade, + 'post', + '/auth/billing/subscribe', + { + body: { planType: 'pro' }, + }, + ); + assert.equal(downgradeRes.statusCode, 409); + + const success = createSetup(); + const successRes = await invoke( + success, + 'post', + '/auth/billing/subscribe', + { + body: { + planType: 'pro', + autoRenew: 1, + }, + }, + ); + assert.deepEqual(successRes.body, { + subscription: { + planType: 'pro', + autoRenew: true, + }, + balanceCents: 6800, + }); +}); + +test('preserves space purchase success and insufficient-balance details', async () => { + const setup = createSetup(); + const success = await invoke( + setup, + 'post', + '/auth/billing/space-purchase', + { + body: { sizeMb: '20' }, + }, + ); + assert.deepEqual(success.body, { + quota: { + quotaBytes: 2048, + }, + balanceCents: 5000, + purchasedMb: 20, + costCents: 4000, + }); + + const limited = createSetup(); + limited.setUserAuth({ + async getMe() { + return { id: 'user-1' }; + }, + async purchaseSpaceQuota() { + return { + ok: false, + code: 'INSUFFICIENT_BALANCE', + message: '余额不足', + balanceCents: 20, + minRechargeCents: 1000, + suggestedTiers: [1000, 5000], + }; + }, + }); + const limitedRes = await invoke( + limited, + 'post', + '/auth/billing/space-purchase', + { + body: { sizeMb: 10 }, + }, + ); + assert.equal(limitedRes.statusCode, 402); + assert.equal( + limitedRes.body.details.minRechargeCents, + 1000, + ); +}); + +test('preserves auto-renew validation and update', async () => { + const setup = createSetup(); + const invalid = await invoke( + setup, + 'post', + '/auth/billing/auto-renew', + { + body: { enabled: 'true' }, + }, + ); + assert.equal(invalid.statusCode, 400); + + const updated = await invoke( + setup, + 'post', + '/auth/billing/auto-renew', + { + body: { enabled: false }, + }, + ); + assert.deepEqual(updated.body, { + ok: true, + enabled: false, + }); +}); + +test('preserves recharge order creation scene mapping and errors', async () => { + const setup = createSetup(); + const res = await invoke( + setup, + 'post', + '/auth/billing/recharge-orders', + { + body: { + amountCents: '3000', + payScene: 'invalid', + }, + ip: '203.0.113.10', + }, + ); + assert.equal(res.statusCode, 201); + assert.ok( + setup.calls.some( + (call) => + call[0] === 'create-order' && + call[1].amountCents === 3000 && + call[1].payScene === 'native' && + call[1].clientIp === '203.0.113.10', + ), + ); + + const failing = createSetup(); + failing.setRechargeService({ + async createOrder() { + return { + ok: false, + message: '金额无效', + }; + }, + }); + const failingRes = await invoke( + failing, + 'post', + '/auth/billing/recharge-orders', + ); + assert.equal(failingRes.statusCode, 400); +}); + +test('preserves recharge order lookup and paid balance projection', async () => { + const setup = createSetup(); + const res = await invoke( + setup, + 'get', + '/auth/billing/recharge-orders/:orderId', + { + params: { orderId: 'order-1' }, + }, + ); + assert.deepEqual(res.body, { + order: { + id: 'order-1', + status: 'paid', + }, + balanceCents: 8800, + }); + + const missing = createSetup(); + missing.setRechargeService({ + async getOrderForUser() { + return null; + }, + }); + const missingRes = await invoke( + missing, + 'get', + '/auth/billing/recharge-orders/:orderId', + { + params: { orderId: 'missing' }, + }, + ); + assert.equal(missingRes.statusCode, 404); +}); + +test('preserves billing service availability and authentication gates', async () => { + const unavailable = createSetup(); + unavailable.setUserAuth(null); + const unavailableRes = await invoke( + unavailable, + 'get', + '/auth/billing/ledger', + ); + assert.equal(unavailableRes.statusCode, 503); + + const unauthorized = createSetup(); + unauthorized.setUserAuth({ + async getMe() { + return null; + }, + }); + const unauthorizedRes = await invoke( + unauthorized, + 'get', + '/auth/billing/plans', + ); + assert.equal(unauthorizedRes.statusCode, 401); +}); diff --git a/server/portal-config-routes.mjs b/server/portal-config-routes.mjs new file mode 100644 index 0000000..0da5d42 --- /dev/null +++ b/server/portal-config-routes.mjs @@ -0,0 +1,49 @@ +function assertRouter(api, attachName) { + if (!api || typeof api.get !== 'function') { + throw new Error(`${attachName} requires an Express-compatible router`); + } +} + +export function attachPortalImageMakeRuntimeConfigRoute( + api, + { + getImageMakeAdminConfigService = () => null, + } = {}, +) { + assertRouter(api, 'attachPortalImageMakeRuntimeConfigRoute'); + + api.get('/internal/image-make/runtime-config', async (req, res) => { + const imageMakeAdminConfigService = getImageMakeAdminConfigService(); + if (!imageMakeAdminConfigService?.authorizeRuntimeRequest) { + return res.status(503).json({ message: 'image_make 配置服务未启用' }); + } + const authHeader = String(req.get('authorization') ?? ''); + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; + if (!imageMakeAdminConfigService.authorizeRuntimeRequest(token)) { + return res.status(401).json({ message: '未授权' }); + } + const runtime = await imageMakeAdminConfigService.getRuntimeConfig(); + if (!runtime.ok) { + return res.status(503).json({ message: runtime.message ?? 'image_make 运行时配置无效' }); + } + return res.json(runtime); + }); +} + +export function attachPortalBlockedWordsRoute( + api, + { + waitForUserAuthReady = async () => {}, + getWordFilterService = () => null, + } = {}, +) { + assertRouter(api, 'attachPortalBlockedWordsRoute'); + + api.get('/config/blocked-words', async (_req, res) => { + await waitForUserAuthReady(); + const wordFilterService = getWordFilterService(); + if (!wordFilterService) return res.json({ words: [] }); + const words = await wordFilterService.listAllForFrontend(); + res.json({ words }); + }); +} diff --git a/server/portal-config-routes.test.mjs b/server/portal-config-routes.test.mjs new file mode 100644 index 0000000..598ce6f --- /dev/null +++ b/server/portal-config-routes.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + attachPortalBlockedWordsRoute, + attachPortalImageMakeRuntimeConfigRoute, +} from './portal-config-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + get(path, handler) { + routes.set(path, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +function createRequest(authorization) { + return { + get(name) { + assert.equal(name, 'authorization'); + return authorization; + }, + }; +} + +test('portal config route module preserves each route inventory', () => { + const imageApi = createRouterRecorder(); + attachPortalImageMakeRuntimeConfigRoute(imageApi); + assert.deepEqual([...imageApi.routes.keys()], ['/internal/image-make/runtime-config']); + + const wordsApi = createRouterRecorder(); + attachPortalBlockedWordsRoute(wordsApi); + assert.deepEqual([...wordsApi.routes.keys()], ['/config/blocked-words']); +}); + +test('GET /internal/image-make/runtime-config preserves unavailable response', async () => { + const api = createRouterRecorder(); + attachPortalImageMakeRuntimeConfigRoute(api); + const res = createResponseRecorder(); + + await api.routes.get('/internal/image-make/runtime-config')(createRequest(), res); + + assert.equal(res.statusCode, 503); + assert.deepEqual(res.body, { message: 'image_make 配置服务未启用' }); +}); + +test('GET /internal/image-make/runtime-config preserves bearer authorization', async () => { + const api = createRouterRecorder(); + let receivedToken = null; + attachPortalImageMakeRuntimeConfigRoute(api, { + getImageMakeAdminConfigService: () => ({ + authorizeRuntimeRequest(token) { + receivedToken = token; + return false; + }, + }), + }); + const res = createResponseRecorder(); + + await api.routes.get('/internal/image-make/runtime-config')( + createRequest('Bearer runtime-token '), + res, + ); + + assert.equal(receivedToken, 'runtime-token'); + assert.equal(res.statusCode, 401); + assert.deepEqual(res.body, { message: '未授权' }); +}); + +test('GET /internal/image-make/runtime-config preserves invalid runtime response', async () => { + const api = createRouterRecorder(); + attachPortalImageMakeRuntimeConfigRoute(api, { + getImageMakeAdminConfigService: () => ({ + authorizeRuntimeRequest: () => true, + async getRuntimeConfig() { + return { ok: false, message: 'runtime incomplete' }; + }, + }), + }); + const res = createResponseRecorder(); + + await api.routes.get('/internal/image-make/runtime-config')(createRequest('Bearer token'), res); + + assert.equal(res.statusCode, 503); + assert.deepEqual(res.body, { message: 'runtime incomplete' }); +}); + +test('GET /internal/image-make/runtime-config preserves successful runtime payload', async () => { + const api = createRouterRecorder(); + const runtime = { ok: true, baseUrl: 'http://image-make.test', token: 'secret' }; + attachPortalImageMakeRuntimeConfigRoute(api, { + getImageMakeAdminConfigService: () => ({ + authorizeRuntimeRequest: (token) => token === 'valid-token', + async getRuntimeConfig() { + return runtime; + }, + }), + }); + const res = createResponseRecorder(); + + await api.routes.get('/internal/image-make/runtime-config')( + createRequest('Bearer valid-token'), + res, + ); + + assert.equal(res.statusCode, 200); + assert.equal(res.body, runtime); +}); + +test('GET /config/blocked-words waits for auth and preserves empty fallback', async () => { + const api = createRouterRecorder(); + let ready = false; + attachPortalBlockedWordsRoute(api, { + waitForUserAuthReady: async () => { + ready = true; + }, + getWordFilterService: () => { + assert.equal(ready, true); + return null; + }, + }); + const res = createResponseRecorder(); + + await api.routes.get('/config/blocked-words')({}, res); + + assert.deepEqual(res.body, { words: [] }); +}); + +test('GET /config/blocked-words preserves frontend word list', async () => { + const api = createRouterRecorder(); + const words = ['alpha', 'beta']; + attachPortalBlockedWordsRoute(api, { + getWordFilterService: () => ({ + async listAllForFrontend() { + return words; + }, + }), + }); + const res = createResponseRecorder(); + + await api.routes.get('/config/blocked-words')({}, res); + + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { words }); +}); diff --git a/server/portal-core-auth-routes.mjs b/server/portal-core-auth-routes.mjs new file mode 100644 index 0000000..38bc03c --- /dev/null +++ b/server/portal-core-auth-routes.mjs @@ -0,0 +1,352 @@ +import { sessionCookie } from '../auth.mjs'; +import { isDatabaseConfigured } from '../db.mjs'; +import { + exchangeMiniProgramCode, + loadWechatMiniappConfig, +} from '../wechat-miniapp.mjs'; + +export function attachPortalCoreAuthRoutes({ + app, + jsonBody, + userAuthReady = Promise.resolve(), + getUserAuth = () => null, + getLegacyAuth = () => null, + userToken, + legacySessionToken, + setUserLoginCookies, + isSecureRequest, + resolveSkillRuntimeForClient = async () => null, + resolveAgentCodeRunForClient = async () => null, + getPlazaSeo = () => null, + plazaClientIp = (req) => req.ip, + logger = console, + isDatabaseConfiguredFn = isDatabaseConfigured, + sessionCookieFn = sessionCookie, + loadWechatMiniappConfigFn = + loadWechatMiniappConfig, + exchangeMiniProgramCodeFn = + exchangeMiniProgramCode, +} = {}) { + if ( + !app || + typeof jsonBody !== 'function' || + typeof userToken !== 'function' || + typeof legacySessionToken !== 'function' || + typeof setUserLoginCookies !== 'function' || + typeof isSecureRequest !== 'function' + ) { + throw new Error( + 'attachPortalCoreAuthRoutes requires route dependencies', + ); + } + + app.get('/auth/status', async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + if (userAuth) { + try { + const me = await userAuth.getMe(userToken(req)); + if (!me) { + return res.json({ + authenticated: false, + mode: 'user', + }); + } + const row = await userAuth.getUserById(me.id); + const capabilityState = + await userAuth.resolveUserCapabilities(row); + const skillRuntime = + await resolveSkillRuntimeForClient(); + const agentCodeRun = + await resolveAgentCodeRunForClient(me.id); + return res.json({ + authenticated: true, + user: me, + mode: 'user', + capabilities: capabilityState.capabilities, + grantedSkills: + capabilityState.grantedSkills ?? [], + unrestricted: capabilityState.unrestricted, + skillRuntime, + agentCodeRun, + }); + } catch (error) { + logger.error( + '[Auth] status failed:', + error instanceof Error + ? error.message + : error, + ); + return res.status(503).json({ + authenticated: false, + mode: 'unavailable', + message: '用户认证服务不可用,请稍后重试', + }); + } + } + if (isDatabaseConfiguredFn()) { + return res.status(503).json({ + authenticated: false, + mode: 'unavailable', + message: '用户认证服务不可用,请稍后重试', + }); + } + const legacyAuth = getLegacyAuth(); + if (legacyAuth) { + return res.json({ + authenticated: legacyAuth.verify( + legacySessionToken(req), + ), + mode: 'legacy', + }); + } + return res.json({ + authenticated: false, + mode: 'none', + }); + }); + + app.post( + '/auth/login', + jsonBody, + async (req, res) => { + await userAuthReady; + const secure = isSecureRequest(req); + const userAuth = getUserAuth(); + + if (userAuth) { + const { username, password } = req.body ?? {}; + if (!username || !password) { + return res.status(400).json({ + message: '用户名和密码不能为空', + }); + } + const result = await userAuth.login({ + username, + password, + ip: req.ip, + }); + if (!result.ok) { + if (result.retryAfterMs > 0) { + res.set( + 'Retry-After', + String( + Math.ceil(result.retryAfterMs / 1000), + ), + ); + return res.status(429).json({ + message: result.message, + }); + } + return res.status(401).json({ + message: result.message, + }); + } + setUserLoginCookies( + res, + req, + result.token, + ); + return res.json({ + authenticated: true, + user: result.user, + mode: 'user', + sessionToken: result.token, + }); + } + + const legacyAuth = getLegacyAuth(); + if (!legacyAuth) { + return res.status(503).json({ + message: '未配置用户数据库或访问密码', + }); + } + const password = + typeof req.body?.password === 'string' + ? req.body.password + : ''; + const result = legacyAuth.login(password, req.ip); + if (!result.ok) { + if (result.retryAfterMs > 0) { + res.set( + 'Retry-After', + String( + Math.ceil(result.retryAfterMs / 1000), + ), + ); + return res.status(429).json({ + message: '尝试次数过多,请稍后再试', + }); + } + return res.status(401).json({ + message: '密码错误,请重试', + }); + } + res.set( + 'Set-Cookie', + sessionCookieFn(result.token, secure), + ); + return res.json({ + authenticated: true, + mode: 'legacy', + }); + }, + ); + + app.post( + '/auth/wechat-miniapp/login', + jsonBody, + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + if (!userAuth) { + return res.status(503).json({ + message: '未启用用户系统', + }); + } + const miniappConfig = + loadWechatMiniappConfigFn(); + if (!miniappConfig.enabled) { + return res.status(503).json({ + message: '小程序登录未配置,请联系管理员', + }); + } + const code = + typeof req.body?.code === 'string' + ? req.body.code.trim() + : ''; + if (!code) { + return res.status(400).json({ + message: '缺少微信登录 code', + }); + } + try { + const session = + await exchangeMiniProgramCodeFn({ + appId: miniappConfig.appId, + appSecret: miniappConfig.appSecret, + code, + }); + const result = + await userAuth.loginByWechatMiniProgram({ + appId: miniappConfig.appId, + openid: session.openid, + unionid: session.unionid, + }); + if (!result.ok) { + return res.status(401).json({ + message: + result.message || '微信登录失败', + }); + } + setUserLoginCookies( + res, + req, + result.token, + ); + return res.json({ + authenticated: true, + user: result.user, + mode: 'user', + isNewUser: Boolean(result.isNewUser), + sessionToken: result.token, + }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : '微信登录失败'; + const status = + error?.code === + 'wechat_miniapp_code_failed' + ? 401 + : 400; + return res.status(status).json({ message }); + } + }, + ); + + app.post( + '/auth/register', + jsonBody, + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + if (!userAuth) { + return res.status(503).json({ + message: '未启用用户注册', + }); + } + const { + username, + password, + displayName, + email, + } = req.body ?? {}; + const result = await userAuth.register({ + username, + password, + displayName, + email, + }); + if (!result.ok) { + const status = result.message.includes( + '已存在', + ) + ? 409 + : 400; + return res.status(status).json({ + message: result.message, + }); + } + const plazaSeo = getPlazaSeo(); + if (plazaSeo && req.body?.utm_source) { + void plazaSeo + .recordAttribution( + { + event_type: 'signup', + utm_source: req.body.utm_source, + utm_medium: req.body.utm_medium, + utm_campaign: req.body.utm_campaign, + ref_id: + req.body.ref ?? req.body.ref_id, + user_id: result.user?.id ?? null, + }, + plazaClientIp(req), + ) + .catch(() => {}); + } + return res.json({ + ok: true, + user: result.user, + }); + }, + ); + + app.post( + '/auth/reset-password', + jsonBody, + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + if (!userAuth) { + return res.status(503).json({ + message: '未启用用户系统', + }); + } + const { username, email, password } = + req.body ?? {}; + const result = await userAuth.resetPassword({ + username, + email, + password, + }); + if (!result.ok) { + return res.status(400).json({ + message: result.message, + }); + } + return res.json({ ok: true }); + }, + ); +} diff --git a/server/portal-core-auth-routes.test.mjs b/server/portal-core-auth-routes.test.mjs new file mode 100644 index 0000000..2331836 --- /dev/null +++ b/server/portal-core-auth-routes.test.mjs @@ -0,0 +1,458 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalCoreAuthRoutes } from './portal-core-auth-routes.mjs'; + +function createResponse() { + return { + statusCode: 200, + headers: {}, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + set(name, value) { + this.headers[name] = value; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +function createSetup(overrides = {}) { + const routes = []; + const calls = []; + let userAuth = { + async getMe(token) { + calls.push(['get-me', token]); + return { id: 'user-1', username: 'john' }; + }, + async getUserById(userId) { + calls.push(['get-user', userId]); + return { id: userId }; + }, + async resolveUserCapabilities(row) { + calls.push(['capabilities', row]); + return { + capabilities: ['chat'], + grantedSkills: ['web'], + unrestricted: false, + }; + }, + async login(options) { + calls.push(['login', options]); + return { + ok: true, + token: 'user-token', + user: { id: 'user-1' }, + }; + }, + async loginByWechatMiniProgram(options) { + calls.push(['miniapp-login', options]); + return { + ok: true, + token: 'mini-token', + user: { id: 'user-2' }, + isNewUser: true, + }; + }, + async register(options) { + calls.push(['register', options]); + return { + ok: true, + user: { id: 'user-3' }, + }; + }, + async resetPassword(options) { + calls.push(['reset-password', options]); + return { ok: true }; + }, + }; + let legacyAuth = null; + const jsonBody = (_req, _res, next) => next(); + const app = { + get(path, ...handlers) { + routes.push({ method: 'get', path, handlers }); + }, + post(path, ...handlers) { + routes.push({ method: 'post', path, handlers }); + }, + }; + const plazaSeo = { + recordAttribution(payload, ip) { + calls.push(['attribution', payload, ip]); + return Promise.resolve(); + }, + }; + const options = { + app, + jsonBody, + userAuthReady: Promise.resolve(), + getUserAuth: () => userAuth, + getLegacyAuth: () => legacyAuth, + userToken: () => 'request-user-token', + legacySessionToken: () => 'legacy-token', + setUserLoginCookies(_res, _req, token) { + calls.push(['set-user-cookies', token]); + }, + isSecureRequest: () => true, + async resolveSkillRuntimeForClient() { + calls.push(['skill-runtime']); + return { enabled: true }; + }, + async resolveAgentCodeRunForClient(userId) { + calls.push(['agent-code-run', userId]); + return { enabled: true, userId }; + }, + getPlazaSeo: () => plazaSeo, + plazaClientIp: () => '203.0.113.5', + logger: { + error(...args) { + calls.push(['error', ...args]); + }, + }, + isDatabaseConfiguredFn: () => false, + sessionCookieFn(token, secure) { + calls.push(['session-cookie', token, secure]); + return `session=${token}`; + }, + loadWechatMiniappConfigFn: () => ({ + enabled: true, + appId: 'mini-app', + appSecret: 'mini-secret', + }), + async exchangeMiniProgramCodeFn(options) { + calls.push(['exchange-code', options]); + return { + openid: 'openid-1', + unionid: 'unionid-1', + }; + }, + ...overrides, + }; + attachPortalCoreAuthRoutes(options); + + return { + calls, + routes, + options, + jsonBody, + setUserAuth(value) { + userAuth = value; + }, + setLegacyAuth(value) { + legacyAuth = value; + }, + route(method, path) { + return routes.find( + (route) => + route.method === method && + route.path === path, + ); + }, + }; +} + +async function invoke(setup, method, path, req = {}) { + const route = setup.route(method, path); + assert.ok(route, `${method.toUpperCase()} ${path}`); + const res = createResponse(); + await route.handlers.at(-1)( + { + body: {}, + ip: '127.0.0.1', + get: () => '', + ...req, + }, + res, + ); + return res; +} + +test('registers the core auth route inventory and middleware order', () => { + const setup = createSetup(); + assert.deepEqual( + setup.routes.map(({ method, path }) => [ + method, + path, + ]), + [ + ['get', '/auth/status'], + ['post', '/auth/login'], + ['post', '/auth/wechat-miniapp/login'], + ['post', '/auth/register'], + ['post', '/auth/reset-password'], + ], + ); + for (const route of setup.routes.filter( + ({ method }) => method === 'post', + )) { + assert.equal(route.handlers[0], setup.jsonBody); + } +}); + +test('returns multi-user status and preserves capability projection', async () => { + const setup = createSetup(); + const res = await invoke( + setup, + 'get', + '/auth/status', + ); + + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { + authenticated: true, + user: { id: 'user-1', username: 'john' }, + mode: 'user', + capabilities: ['chat'], + grantedSkills: ['web'], + unrestricted: false, + skillRuntime: { enabled: true }, + agentCodeRun: { enabled: true, userId: 'user-1' }, + }); +}); + +test('preserves unavailable, legacy, and disabled status modes', async () => { + const unavailable = createSetup({ + isDatabaseConfiguredFn: () => true, + }); + unavailable.setUserAuth(null); + assert.equal( + ( + await invoke( + unavailable, + 'get', + '/auth/status', + ) + ).statusCode, + 503, + ); + + const legacy = createSetup(); + legacy.setUserAuth(null); + legacy.setLegacyAuth({ + verify: (token) => token === 'legacy-token', + }); + assert.deepEqual( + ( + await invoke(legacy, 'get', '/auth/status') + ).body, + { authenticated: true, mode: 'legacy' }, + ); + + const disabled = createSetup(); + disabled.setUserAuth(null); + assert.deepEqual( + ( + await invoke(disabled, 'get', '/auth/status') + ).body, + { authenticated: false, mode: 'none' }, + ); +}); + +test('preserves multi-user and legacy login flows', async () => { + const setup = createSetup(); + const userRes = await invoke( + setup, + 'post', + '/auth/login', + { + body: { + username: 'john', + password: 'secret', + }, + ip: '203.0.113.10', + }, + ); + assert.deepEqual(userRes.body, { + authenticated: true, + user: { id: 'user-1' }, + mode: 'user', + sessionToken: 'user-token', + }); + assert.ok( + setup.calls.some( + ([name, token]) => + name === 'set-user-cookies' && + token === 'user-token', + ), + ); + + const legacy = createSetup(); + legacy.setUserAuth(null); + legacy.setLegacyAuth({ + login(password, ip) { + assert.equal(password, 'legacy-secret'); + assert.equal(ip, '127.0.0.1'); + return { ok: true, token: 'legacy-session' }; + }, + }); + const legacyRes = await invoke( + legacy, + 'post', + '/auth/login', + { body: { password: 'legacy-secret' } }, + ); + assert.deepEqual(legacyRes.body, { + authenticated: true, + mode: 'legacy', + }); + assert.equal( + legacyRes.headers['Set-Cookie'], + 'session=legacy-session', + ); +}); + +test('preserves login validation and retry limits', async () => { + const missing = createSetup(); + assert.equal( + ( + await invoke( + missing, + 'post', + '/auth/login', + ) + ).statusCode, + 400, + ); + + const limited = createSetup(); + limited.setUserAuth({ + async login() { + return { + ok: false, + retryAfterMs: 1500, + message: 'limited', + }; + }, + }); + const limitedRes = await invoke( + limited, + 'post', + '/auth/login', + { + body: { username: 'john', password: 'bad' }, + }, + ); + assert.equal(limitedRes.statusCode, 429); + assert.equal(limitedRes.headers['Retry-After'], '2'); +}); + +test('preserves mini-program exchange and login mapping', async () => { + const setup = createSetup(); + const res = await invoke( + setup, + 'post', + '/auth/wechat-miniapp/login', + { body: { code: ' code-1 ' } }, + ); + + assert.deepEqual(res.body, { + authenticated: true, + user: { id: 'user-2' }, + mode: 'user', + isNewUser: true, + sessionToken: 'mini-token', + }); + assert.ok( + setup.calls.some( + ([name, options]) => + name === 'exchange-code' && + options.code === 'code-1', + ), + ); + assert.ok( + setup.calls.some( + ([name, options]) => + name === 'miniapp-login' && + options.openid === 'openid-1', + ), + ); +}); + +test('preserves mini-program configuration and exchange failures', async () => { + const disabled = createSetup({ + loadWechatMiniappConfigFn: () => ({ + enabled: false, + }), + }); + assert.equal( + ( + await invoke( + disabled, + 'post', + '/auth/wechat-miniapp/login', + { body: { code: 'code-1' } }, + ) + ).statusCode, + 503, + ); + + const failed = createSetup({ + async exchangeMiniProgramCodeFn() { + const error = new Error('code rejected'); + error.code = 'wechat_miniapp_code_failed'; + throw error; + }, + }); + const failedRes = await invoke( + failed, + 'post', + '/auth/wechat-miniapp/login', + { body: { code: 'code-1' } }, + ); + assert.equal(failedRes.statusCode, 401); + assert.deepEqual(failedRes.body, { + message: 'code rejected', + }); +}); + +test('preserves registration attribution and password reset', async () => { + const setup = createSetup(); + const registerRes = await invoke( + setup, + 'post', + '/auth/register', + { + body: { + username: 'john', + password: 'secret', + displayName: 'John', + email: 'john@example.com', + utm_source: 'campaign', + utm_medium: 'wechat', + ref: 'ref-1', + }, + }, + ); + assert.deepEqual(registerRes.body, { + ok: true, + user: { id: 'user-3' }, + }); + assert.ok( + setup.calls.some( + ([name, payload, ip]) => + name === 'attribution' && + payload.user_id === 'user-3' && + payload.utm_source === 'campaign' && + ip === '203.0.113.5', + ), + ); + + const resetRes = await invoke( + setup, + 'post', + '/auth/reset-password', + { + body: { + username: 'john', + email: 'john@example.com', + password: 'new-secret', + }, + }, + ); + assert.deepEqual(resetRes.body, { ok: true }); +}); diff --git a/server/portal-domain-services-bootstrap.mjs b/server/portal-domain-services-bootstrap.mjs new file mode 100644 index 0000000..1d68b58 --- /dev/null +++ b/server/portal-domain-services-bootstrap.mjs @@ -0,0 +1,326 @@ +import { ensureMindSpaceConfig, loadMindSpaceConfig } from '../mindspace-config.mjs'; +import { createMindSearchConfigService } from '../mindsearch-config.mjs'; +import { createMindSpaceService } from '../mindspace.mjs'; +import { + assertMindSpaceServerAdapterContract, + createMindSpaceServerAdapter, +} from '../mindspace-server-adapter.mjs'; +import { + resolveMindSpaceRuntimeConfig, +} from '../mindspace-runtime-config.mjs'; +import { createWorkspacePageDeliverService } from '../mindspace-workspace-page-deliver.mjs'; +import { ensurePageDataHtmlPagesBound } from '../page-data-workspace-ensure.mjs'; +import { createPageDataPublicService } from '../page-data-public-service.mjs'; +import { createPageDataService } from '../page-data-service.mjs'; +import { + ensureAlgorithmConfig, + loadAlgorithmConfig, + recalculateHotScores, +} from '../plaza-algorithm.mjs'; +import { createPlazaEventService } from '../plaza-events.mjs'; +import { createPlazaInteractionService } from '../plaza-interactions.mjs'; +import { createPlazaOpsService } from '../plaza-ops.mjs'; +import { createPlazaPostService, formatPostRow } from '../plaza-posts.mjs'; +import { createPlazaRecommendService } from '../plaza-recommend.mjs'; +import { createPlazaRedis } from '../plaza-redis.mjs'; +import { createPlazaSeoService } from '../plaza-seo.mjs'; +import { + startPlazaTasks, + writebackPublications, +} from '../plaza-tasks.mjs'; +import { createScheduleService } from '../schedule-service.mjs'; +import { createFeedbackService } from '../user-feedback.mjs'; +import { PUBLISH_KEY_UUID } from '../user-publish.mjs'; +import { initSchema } from '../db.mjs'; + +export async function bootstrapPortalDomainServices({ + pool, + h5Root, + env = process.env, + runtime, + analyticsConfig = {}, + getUserAuth = () => null, + getSessionSnapshotService = () => null, + onMindSearchSchemaReady = () => {}, + registerPublicHtmlArtifactsForConversation, + workspaceMaintenanceEnabled = true, + logger = console, + initSchemaFn = initSchema, + createMindSearchConfigServiceFn = + createMindSearchConfigService, + ensureMindSpaceConfigFn = ensureMindSpaceConfig, + loadMindSpaceConfigFn = loadMindSpaceConfig, + createScheduleServiceFn = createScheduleService, + createPageDataServiceFn = createPageDataService, + createPageDataPublicServiceFn = + createPageDataPublicService, + createFeedbackServiceFn = createFeedbackService, + createMindSpaceServiceFn = createMindSpaceService, + createMindSpaceServerAdapterFn = + createMindSpaceServerAdapter, + assertMindSpaceServerAdapterContractFn = + assertMindSpaceServerAdapterContract, + createWorkspacePageDeliverServiceFn = + createWorkspacePageDeliverService, + ensurePageDataHtmlPagesBoundFn = + ensurePageDataHtmlPagesBound, + resolveMindSpaceRuntimeConfigFn = + resolveMindSpaceRuntimeConfig, + ensureAlgorithmConfigFn = ensureAlgorithmConfig, + loadAlgorithmConfigFn = loadAlgorithmConfig, + createPlazaRedisFn = createPlazaRedis, + createPlazaSeoServiceFn = createPlazaSeoService, + createPlazaInteractionServiceFn = + createPlazaInteractionService, + createPlazaEventServiceFn = createPlazaEventService, + createPlazaRecommendServiceFn = + createPlazaRecommendService, + createPlazaPostServiceFn = createPlazaPostService, + createPlazaOpsServiceFn = createPlazaOpsService, + startPlazaTasksFn = startPlazaTasks, + recalculateHotScoresFn = recalculateHotScores, + writebackPublicationsFn = writebackPublications, + formatPostRowFn = formatPostRow, + publishKeyUuid = PUBLISH_KEY_UUID, +} = {}) { + if (!pool || !runtime) { + throw new Error( + 'bootstrapPortalDomainServices requires pool and runtime', + ); + } + if ( + typeof registerPublicHtmlArtifactsForConversation !== + 'function' + ) { + throw new Error( + 'bootstrapPortalDomainServices requires artifact registration', + ); + } + + const mindSearchConfigService = + createMindSearchConfigServiceFn(pool); + await mindSearchConfigService.ensureSchema(); + await onMindSearchSchemaReady({ + pool, + mindSearchConfigService, + }); + await initSchemaFn(pool); + await ensureMindSpaceConfigFn(pool, { env }); + const storedMindSpaceConfig = + await loadMindSpaceConfigFn(pool, { + env, + includeAnalyticsSecret: true, + }); + let resolvedAnalyticsConfig = analyticsConfig; + if (storedMindSpaceConfig?.analytics) { + resolvedAnalyticsConfig = { + ...analyticsConfig, + ...storedMindSpaceConfig.analytics, + enabled: Boolean( + storedMindSpaceConfig.analytics.enabled && + storedMindSpaceConfig.analytics.websiteId && + storedMindSpaceConfig.analytics.idSecret, + ), + hostPath: '/analytics', + scriptPath: '/analytics/script.js', + }; + } + + const scheduleService = createScheduleServiceFn(pool, { + defaultTimezone: + env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', + }); + const pageDataService = createPageDataServiceFn({ + getUserAuth, + getPool: () => pool, + resolveWorkspaceRoot: async (user) => { + if (user?.workspaceRoot) return user.workspaceRoot; + const userAuth = getUserAuth(); + if (!userAuth || !user?.id) return null; + return userAuth.resolveWorkingDir(user.id); + }, + }); + const pageDataPublicService = + createPageDataPublicServiceFn({ + getPool: () => pool, + resolveH5Root: () => h5Root, + }); + const feedbackService = createFeedbackServiceFn(pool); + const mindSpace = createMindSpaceServiceFn(pool, { + maxFileBytes: runtime.maxFileBytes, + aiDailyLimit: runtime.aiDailyLimit, + publicPageLimit: runtime.publicPageLimit, + monthlyViewLimit: runtime.monthlyViewLimit, + scheduleService, + }); + + const resolveUserIdForAgentSession = async (sessionId) => { + const [rows] = await pool.query( + `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, + [sessionId], + ); + return rows[0]?.user_id ?? null; + }; + const mindSpaceRuntimeAdapter = + assertMindSpaceServerAdapterContractFn( + createMindSpaceServerAdapterFn({ + pool, + h5Root, + env, + maxFileBytes: runtime.maxFileBytes, + publicPageLimit: runtime.publicPageLimit, + resolveUserIdForAgentSession, + resolveWorkspaceRoot: (userId) => + getUserAuth().resolveWorkingDir(userId), + resolveSessionSnapshot: (sessionId) => + getSessionSnapshotService()?.get?.(sessionId), + registerPublicHtmlArtifactsForConversation, + syncWorkspaceAssetsEnabled: + workspaceMaintenanceEnabled, + remote: runtime.remote, + logger, + }), + ); + mindSpaceRuntimeAdapter.assertReady?.(); + + const mindSpaceServiceFacade = + mindSpaceRuntimeAdapter.serviceFacade; + const mindSpaceConversationPackageRegistry = + mindSpaceRuntimeAdapter.conversationPackageRegistry; + const mindSpaceAssets = + mindSpaceRuntimeAdapter.assetService; + const mindSpacePages = mindSpaceRuntimeAdapter.pageService; + const mindSpacePageSync = + mindSpaceRuntimeAdapter.pageSyncService; + const mindSpacePageLiveEdit = + mindSpaceRuntimeAdapter.pageLiveEditService; + const mindSpaceAssetAgent = + mindSpaceRuntimeAdapter.assetAgentService; + const mindSpacePublications = + mindSpaceRuntimeAdapter.publicationService; + const workspacePageDeliver = + createWorkspacePageDeliverServiceFn({ + pool, + pageService: mindSpacePages, + publicationService: mindSpacePublications, + pageSyncService: mindSpacePageSync, + pageDataEnsure: { + ensurePageDataHtmlPagesBound: + ensurePageDataHtmlPagesBoundFn, + }, + h5Root, + storageRoot: + resolveMindSpaceRuntimeConfigFn(h5Root, env) + .storageRoot, + findPageByRelativePath: + mindSpacePages.findPageByRelativePath.bind( + mindSpacePages, + ), + logger, + }); + + const resolveUserIdByDirKey = async (dirKey) => { + let userId = dirKey; + if (!publishKeyUuid.test(dirKey)) { + const [rows] = await pool.query( + `SELECT id FROM h5_users WHERE username = ? LIMIT 1`, + [dirKey], + ); + userId = rows[0]?.id; + } + return userId ?? null; + }; + + await ensureAlgorithmConfigFn(pool); + const plazaAlgorithmConfig = + await loadAlgorithmConfigFn(pool); + const plazaRedis = await createPlazaRedisFn( + env.PLAZA_REDIS_URL, + pool, + ); + if (plazaRedis.enabled) { + logger.log('Plaza Redis enabled'); + } + const plazaSeo = createPlazaSeoServiceFn(pool); + const plazaInteractions = + createPlazaInteractionServiceFn(pool, { + formatPostRow: formatPostRowFn, + plazaRedis, + }); + const plazaEvents = createPlazaEventServiceFn(pool); + const plazaRecommend = createPlazaRecommendServiceFn(pool, { + eventService: plazaEvents, + formatPostRow: formatPostRowFn, + loadViewerReactions: (viewerId, postIds) => + plazaInteractions.loadViewerReactions( + viewerId, + postIds, + ), + algorithmConfig: plazaAlgorithmConfig, + }); + let plazaOps = null; + const plazaPosts = createPlazaPostServiceFn(pool, { + loadViewerReactions: (viewerId, postIds) => + plazaInteractions.loadViewerReactions( + viewerId, + postIds, + ), + plazaRedis, + algorithmConfig: plazaAlgorithmConfig, + recommendService: plazaRecommend, + onPostPublished: (postId) => + plazaSeo?.notifyPostPublished(postId), + loadFeaturedPosts: async (viewerId) => { + if (!plazaOps) { + return { + homepage_banner: [], + trending: [], + category_top: {}, + }; + } + return plazaOps.loadActiveFeaturedPosts(viewerId); + }, + }); + plazaOps = createPlazaOpsServiceFn(pool, { + formatPostRow: formatPostRowFn, + reviewPost: (...args) => plazaPosts.reviewPost(...args), + invalidateFeedCaches: () => + plazaRedis?.invalidateFeedCaches?.(), + }); + startPlazaTasksFn({ + pool, + plazaRedis, + recalculateHotScores: recalculateHotScoresFn, + writebackPublications: writebackPublicationsFn, + }); + + return { + mindSearchConfigService, + mindSpaceAnalyticsConfig: resolvedAnalyticsConfig, + scheduleService, + pageDataService, + pageDataPublicService, + feedbackService, + mindSpace, + mindSpaceRuntimeAdapter, + mindSpaceServiceFacade, + mindSpaceConversationPackageRegistry, + mindSpaceAssets, + mindSpacePages, + mindSpacePageSync, + mindSpacePageLiveEdit, + mindSpaceAssetAgent, + mindSpacePublications, + workspacePageDeliver, + resolveUserIdByDirKey, + plazaRedis, + plazaSeo, + plazaInteractions, + plazaEvents, + plazaRecommend, + plazaPosts, + plazaOps, + mindSpaceCleanup: + mindSpaceRuntimeAdapter.cleanupService, + }; +} diff --git a/server/portal-domain-services-bootstrap.test.mjs b/server/portal-domain-services-bootstrap.test.mjs new file mode 100644 index 0000000..72a7f09 --- /dev/null +++ b/server/portal-domain-services-bootstrap.test.mjs @@ -0,0 +1,418 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { bootstrapPortalDomainServices } from './portal-domain-services-bootstrap.mjs'; + +function createBootstrapSetup(overrides = {}) { + const calls = []; + const pool = { + async query(sql, params) { + calls.push({ kind: 'query', sql, params }); + if (sql.includes('agent_session_id')) { + return [[{ user_id: 'agent-user' }]]; + } + if (sql.includes('username')) { + return [[{ id: 'named-user' }]]; + } + return [[]]; + }, + }; + const pageService = { + findPageByRelativePath() {}, + }; + const adapter = { + serviceFacade: { id: 'facade' }, + conversationPackageRegistry: { id: 'registry' }, + assetService: { id: 'assets' }, + pageService, + pageSyncService: { id: 'page-sync' }, + pageLiveEditService: { id: 'live-edit' }, + assetAgentService: { id: 'asset-agent' }, + publicationService: { id: 'publications' }, + cleanupService: { id: 'cleanup' }, + assertReady() { + calls.push({ kind: 'adapter-ready' }); + }, + }; + const plazaRedis = { + enabled: true, + invalidateFeedCaches() { + calls.push({ kind: 'invalidate-feed' }); + }, + }; + let adapterOptions = null; + let pageDataOptions = null; + let pageDataPublicOptions = null; + let workspaceDeliverOptions = null; + let plazaPostOptions = null; + let plazaOpsOptions = null; + const dependencies = { + pool, + h5Root: '/workspace', + env: { + H5_DEFAULT_TIMEZONE: 'Asia/Singapore', + PLAZA_REDIS_URL: 'redis://plaza', + }, + runtime: { + maxFileBytes: 100, + aiDailyLimit: 2, + publicPageLimit: 3, + monthlyViewLimit: 4, + remote: { enabled: false }, + }, + analyticsConfig: { + enabled: false, + hostPath: '/old', + }, + getUserAuth: () => ({ + resolveWorkingDir: (userId) => `/users/${userId}`, + }), + getSessionSnapshotService: () => ({ + get: (sessionId) => ({ sessionId }), + }), + onMindSearchSchemaReady({ pool: receivedPool }) { + assert.equal(receivedPool, pool); + calls.push({ kind: 'auth-pool-ready' }); + }, + registerPublicHtmlArtifactsForConversation() {}, + workspaceMaintenanceEnabled: true, + logger: { + log(message) { + calls.push({ kind: 'log', message }); + }, + }, + async initSchemaFn(receivedPool) { + assert.equal(receivedPool, pool); + calls.push({ kind: 'init-schema' }); + }, + createMindSearchConfigServiceFn(receivedPool) { + assert.equal(receivedPool, pool); + return { + async ensureSchema() { + calls.push({ kind: 'mind-search-schema' }); + }, + }; + }, + async ensureMindSpaceConfigFn(receivedPool, options) { + assert.equal(receivedPool, pool); + calls.push({ kind: 'mindspace-config', options }); + }, + async loadMindSpaceConfigFn() { + calls.push({ kind: 'load-mindspace-config' }); + return { + analytics: { + enabled: true, + websiteId: 'website-1', + idSecret: 'secret-1', + }, + }; + }, + createScheduleServiceFn(receivedPool, options) { + calls.push({ kind: 'schedule', receivedPool, options }); + return { id: 'schedule' }; + }, + createPageDataServiceFn(options) { + pageDataOptions = options; + return { id: 'page-data' }; + }, + createPageDataPublicServiceFn(options) { + pageDataPublicOptions = options; + return { id: 'page-data-public' }; + }, + createFeedbackServiceFn() { + return { id: 'feedback' }; + }, + createMindSpaceServiceFn(receivedPool, options) { + calls.push({ kind: 'mindspace', receivedPool, options }); + return { id: 'mindspace' }; + }, + createMindSpaceServerAdapterFn(options) { + adapterOptions = options; + return adapter; + }, + assertMindSpaceServerAdapterContractFn(received) { + calls.push({ kind: 'adapter-contract' }); + return received; + }, + createWorkspacePageDeliverServiceFn(options) { + workspaceDeliverOptions = options; + return { id: 'workspace-deliver' }; + }, + ensurePageDataHtmlPagesBoundFn() {}, + resolveMindSpaceRuntimeConfigFn() { + return { storageRoot: '/storage' }; + }, + async ensureAlgorithmConfigFn() { + calls.push({ kind: 'algorithm-schema' }); + }, + async loadAlgorithmConfigFn() { + calls.push({ kind: 'algorithm-load' }); + return { version: 1 }; + }, + async createPlazaRedisFn(url, receivedPool) { + calls.push({ kind: 'redis', url, receivedPool }); + return plazaRedis; + }, + createPlazaSeoServiceFn() { + return { + notifyPostPublished(postId) { + calls.push({ kind: 'seo-published', postId }); + }, + }; + }, + createPlazaInteractionServiceFn(_pool, options) { + calls.push({ kind: 'interactions', options }); + return { + async loadViewerReactions(viewerId, postIds) { + calls.push({ + kind: 'viewer-reactions', + viewerId, + postIds, + }); + return {}; + }, + }; + }, + createPlazaEventServiceFn() { + return { id: 'events' }; + }, + createPlazaRecommendServiceFn(_pool, options) { + calls.push({ kind: 'recommend', options }); + return { id: 'recommend' }; + }, + createPlazaPostServiceFn(_pool, options) { + plazaPostOptions = options; + return { + reviewPost(...args) { + calls.push({ kind: 'review-post', args }); + }, + }; + }, + createPlazaOpsServiceFn(_pool, options) { + plazaOpsOptions = options; + return { + async loadActiveFeaturedPosts(viewerId) { + return { viewerId }; + }, + }; + }, + startPlazaTasksFn(options) { + calls.push({ kind: 'plaza-tasks', options }); + }, + recalculateHotScoresFn() {}, + writebackPublicationsFn() {}, + formatPostRowFn() {}, + publishKeyUuid: /^uuid-/, + ...overrides, + }; + return { + calls, + pool, + adapter, + pageService, + plazaRedis, + dependencies, + captured: { + get adapterOptions() { + return adapterOptions; + }, + get pageDataOptions() { + return pageDataOptions; + }, + get pageDataPublicOptions() { + return pageDataPublicOptions; + }, + get workspaceDeliverOptions() { + return workspaceDeliverOptions; + }, + get plazaPostOptions() { + return plazaPostOptions; + }, + get plazaOpsOptions() { + return plazaOpsOptions; + }, + }, + }; +} + +test('domain bootstrap requires pool, runtime, and artifact registration', async () => { + await assert.rejects( + () => bootstrapPortalDomainServices(), + /requires pool and runtime/, + ); + await assert.rejects( + () => + bootstrapPortalDomainServices({ + pool: {}, + runtime: {}, + }), + /requires artifact registration/, + ); +}); + +test('domain bootstrap preserves schema and service assembly order', async () => { + const setup = createBootstrapSetup(); + const result = await bootstrapPortalDomainServices( + setup.dependencies, + ); + assert.deepEqual( + setup.calls + .filter((call) => + [ + 'mind-search-schema', + 'auth-pool-ready', + 'init-schema', + 'mindspace-config', + 'load-mindspace-config', + 'adapter-contract', + 'adapter-ready', + 'algorithm-schema', + 'algorithm-load', + 'redis', + 'plaza-tasks', + ].includes(call.kind), + ) + .map((call) => call.kind), + [ + 'mind-search-schema', + 'auth-pool-ready', + 'init-schema', + 'mindspace-config', + 'load-mindspace-config', + 'adapter-contract', + 'adapter-ready', + 'algorithm-schema', + 'algorithm-load', + 'redis', + 'plaza-tasks', + ], + ); + assert.equal(result.mindSpace.id, 'mindspace'); + assert.equal(result.mindSpaceAssets.id, 'assets'); + assert.equal(result.mindSpacePages, setup.pageService); + assert.equal(result.workspacePageDeliver.id, 'workspace-deliver'); + assert.equal(result.mindSpaceCleanup.id, 'cleanup'); + assert.equal(result.plazaRedis, setup.plazaRedis); + assert.equal( + result.mindSpaceAnalyticsConfig.enabled, + true, + ); + assert.equal( + result.mindSpaceAnalyticsConfig.hostPath, + '/analytics', + ); + assert.equal( + result.mindSpaceAnalyticsConfig.scriptPath, + '/analytics/script.js', + ); +}); + +test('domain bootstrap preserves delayed user, snapshot, and Page Data dependencies', async () => { + let activeUserAuth = { + resolveWorkingDir: (userId) => `/first/${userId}`, + }; + const snapshot = { id: 'snapshot-1' }; + const setup = createBootstrapSetup({ + getUserAuth: () => activeUserAuth, + getSessionSnapshotService: () => ({ + get: () => snapshot, + }), + }); + await bootstrapPortalDomainServices(setup.dependencies); + + assert.equal( + await setup.captured.pageDataOptions.resolveWorkspaceRoot({ + workspaceRoot: '/explicit', + }), + '/explicit', + ); + activeUserAuth = { + resolveWorkingDir: (userId) => `/second/${userId}`, + }; + assert.equal( + await setup.captured.pageDataOptions.resolveWorkspaceRoot({ + id: 'user-1', + }), + '/second/user-1', + ); + assert.equal( + setup.captured.pageDataPublicOptions.getPool(), + setup.pool, + ); + assert.equal( + setup.captured.pageDataPublicOptions.resolveH5Root(), + '/workspace', + ); + assert.equal( + setup.captured.adapterOptions.resolveWorkspaceRoot( + 'user-2', + ), + '/second/user-2', + ); + assert.equal( + setup.captured.adapterOptions.resolveSessionSnapshot( + 'session-1', + ), + snapshot, + ); + assert.equal( + await setup.captured.adapterOptions.resolveUserIdForAgentSession( + 'session-1', + ), + 'agent-user', + ); + assert.equal( + setup.captured.adapterOptions.syncWorkspaceAssetsEnabled, + true, + ); + assert.equal( + setup.captured.workspaceDeliverOptions.storageRoot, + '/storage', + ); +}); + +test('domain bootstrap preserves directory and Plaza cross-service callbacks', async () => { + const setup = createBootstrapSetup(); + const result = await bootstrapPortalDomainServices( + setup.dependencies, + ); + assert.equal( + await result.resolveUserIdByDirKey('uuid-user'), + 'uuid-user', + ); + assert.equal( + await result.resolveUserIdByDirKey('alice'), + 'named-user', + ); + + assert.deepEqual( + await setup.captured.plazaPostOptions.loadFeaturedPosts( + 'viewer-1', + ), + { viewerId: 'viewer-1' }, + ); + setup.captured.plazaPostOptions.onPostPublished('post-1'); + await setup.captured.plazaPostOptions.loadViewerReactions( + 'viewer-1', + ['post-1'], + ); + setup.captured.plazaOpsOptions.reviewPost('post-1'); + setup.captured.plazaOpsOptions.invalidateFeedCaches(); + assert.deepEqual( + setup.calls + .filter((call) => + [ + 'seo-published', + 'viewer-reactions', + 'review-post', + 'invalidate-feed', + ].includes(call.kind), + ) + .map((call) => call.kind), + [ + 'seo-published', + 'viewer-reactions', + 'review-post', + 'invalidate-feed', + ], + ); +}); diff --git a/server/portal-gateway-services-bootstrap.mjs b/server/portal-gateway-services-bootstrap.mjs new file mode 100644 index 0000000..73fd46a --- /dev/null +++ b/server/portal-gateway-services-bootstrap.mjs @@ -0,0 +1,261 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createAgentRunGateway } from '../agent-run-gateway.mjs'; +import { scanWorkspaceFilesForProhibitedBrowserStorage } from '../mindspace-browser-storage-policy.mjs'; +import { evaluatePageDataHtmlContent } from '../mindspace-page-data-finish-guard.mjs'; +import { normalizeWorkspaceRelativePath } from '../mindspace-pages.mjs'; +import { resolveMindSpaceUserPublishDir } from '../mindspace-runtime-config.mjs'; +import { policyAllowsAction } from '../page-access-policy.mjs'; +import { detectPageDataDatasetUsageFromHtml } from '../page-data-html-detect.mjs'; +import { readPageAccessPolicy } from '../page-data-policy-store.mjs'; +import { createTkmindProxy } from '../tkmind-proxy.mjs'; +import { createToolGateway } from '../tool-gateway.mjs'; + +function isEnabledFlag(value, fallback = '') { + return ['1', 'true', 'yes', 'on'].includes( + String(value ?? fallback).trim().toLowerCase(), + ); +} + +export function createPortalRunDeliverablesValidator({ + h5Root, + resolveMindSpaceUserPublishDirFn = + resolveMindSpaceUserPublishDir, + normalizeWorkspaceRelativePathFn = + normalizeWorkspaceRelativePath, + evaluatePageDataHtmlContentFn = + evaluatePageDataHtmlContent, + readPageAccessPolicyFn = readPageAccessPolicy, + detectPageDataDatasetUsageFromHtmlFn = + detectPageDataDatasetUsageFromHtml, + policyAllowsActionFn = policyAllowsAction, + scanWorkspaceFilesForProhibitedBrowserStorageFn = + scanWorkspaceFilesForProhibitedBrowserStorage, + resolvePathFn = path.resolve, + pathSeparator = path.sep, + existsSyncFn = fs.existsSync, + readFileSyncFn = fs.readFileSync, +} = {}) { + if (!h5Root) { + throw new Error( + 'createPortalRunDeliverablesValidator requires h5Root', + ); + } + + return async function validateRunDeliverables({ + userId, + deliverables, + }) { + const publishDir = + resolveMindSpaceUserPublishDirFn(h5Root, { + id: userId, + }); + const resolvedPublishDir = + resolvePathFn(publishDir); + const pageDataErrors = []; + + for (const page of deliverables?.pages ?? []) { + const relativePath = + normalizeWorkspaceRelativePathFn( + page.workspaceRelativePath, + ); + if (!relativePath?.startsWith('public/')) continue; + const filePath = resolvePathFn( + publishDir, + relativePath, + ); + if ( + !filePath.startsWith( + `${resolvedPublishDir}${pathSeparator}`, + ) || + !existsSyncFn(filePath) + ) { + continue; + } + const html = readFileSyncFn(filePath, 'utf8'); + const evaluation = + evaluatePageDataHtmlContentFn(html, { + relativePath, + }); + if (!evaluation.usesPageDataApi) continue; + + for (const issue of evaluation.issues) { + pageDataErrors.push({ + code: issue, + message: `${relativePath} Page Data HTML 不可交付:${issue}`, + }); + } + const policy = page.pageId + ? readPageAccessPolicyFn( + publishDir, + page.pageId, + ) + : null; + for (const [dataset, actions] of + detectPageDataDatasetUsageFromHtmlFn(html)) { + for (const action of ['read', 'insert']) { + if ( + actions?.[action] && + !policyAllowsActionFn( + policy, + dataset, + action, + ) + ) { + pageDataErrors.push({ + code: 'page_data_policy_action_missing', + message: `${relativePath} 的 ${dataset}.${action} 未获最终 policy 授权或 dataset 已关闭`, + }); + } + } + } + } + + const violations = + scanWorkspaceFilesForProhibitedBrowserStorageFn({ + publishDir, + relativePaths: (deliverables?.pages ?? []) + .map((page) => page.workspaceRelativePath) + .filter(Boolean), + }); + return { + errors: [ + ...pageDataErrors, + ...violations.map((violation) => ({ + code: 'browser_storage_forbidden', + message: `${violation.relativePath} 使用 ${violation.apis.join(', ')}`, + })), + ], + }; + }; +} + +export function bootstrapPortalGatewayServices({ + pool, + h5Root, + env = process.env, + apiTarget, + apiTargets, + apiSecret, + userAuth, + sessionAccess, + sessionStreamStore, + llmProviderService, + subscriptionService, + sessionSnapshotService, + conversationMemoryService, + memoryV2, + mindSpaceAssets, + directChatService, + chatIntentRouter, + syncUserGeneratedPages, + isSessionPageDeliveryActive, + createTkmindProxyFn = createTkmindProxy, + createToolGatewayFn = createToolGateway, + createAgentRunGatewayFn = createAgentRunGateway, + createRunDeliverablesValidatorFn = + createPortalRunDeliverablesValidator, + readAssetFileFn = fs.promises.readFile, +} = {}) { + if ( + !pool || + !h5Root || + !userAuth || + !sessionAccess || + !llmProviderService || + typeof syncUserGeneratedPages !== 'function' || + typeof isSessionPageDeliveryActive !== 'function' + ) { + throw new Error( + 'bootstrapPortalGatewayServices requires gateway dependencies', + ); + } + + // GOOSED PROXY BOUNDARY: H5 chat → goosed unique entry. + const tkmindProxy = createTkmindProxyFn({ + apiTarget, + apiTargets, + apiSecret, + userAuth, + sessionAccess, + sessionStreamStore, + llmProviderService, + subscriptionService, + sessionSnapshotService, + conversationMemoryService, + memoryV2, + localFetchAsset: mindSpaceAssets + ? async (userId, assetId) => { + const { asset, path: assetPath } = + await mindSpaceAssets.readAsset( + userId, + assetId, + ); + const buffer = + await readAssetFileFn(assetPath); + return { + buffer, + mimeType: asset.mimeType, + }; + } + : null, + }); + const toolGateway = createToolGatewayFn({ + llmProviderService, + }); + const validateRunDeliverables = + createRunDeliverablesValidatorFn({ h5Root }); + const agentRunGateway = createAgentRunGatewayFn({ + pool, + userAuth, + sessionAccess, + tkmindProxy, + toolGateway, + directChatService, + chatIntentRouter, + sessionSnapshotService, + conversationMemoryService, + observePersonalMemoryOnSuccess: async ({ + userId, + sessionId, + userMessage, + }) => { + if (!memoryV2?.observePersonalMemory) return; + await memoryV2.observePersonalMemory({ + userId, + sessionId, + messages: [userMessage], + }); + }, + syncUserPagesOnSuccess: async ({ + userId, + sessionId, + runStartedAtMs, + }) => + syncUserGeneratedPages(userId, { + sessionId, + sinceMs: runStartedAtMs, + }), + isSessionExternallyBusy: ({ sessionId }) => + isSessionPageDeliveryActive(sessionId), + validateRunDeliverables, + autoDispatch: isEnabledFlag( + env.MEMIND_AGENT_RUN_AUTODISPATCH, + '1', + ), + maxConcurrentRuns: Number( + env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1, + ), + runTimeoutMs: Number( + env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? + 15 * 60 * 1000, + ), + }); + + return { + tkmindProxy, + toolGateway, + agentRunGateway, + validateRunDeliverables, + }; +} diff --git a/server/portal-gateway-services-bootstrap.test.mjs b/server/portal-gateway-services-bootstrap.test.mjs new file mode 100644 index 0000000..9b10251 --- /dev/null +++ b/server/portal-gateway-services-bootstrap.test.mjs @@ -0,0 +1,356 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + bootstrapPortalGatewayServices, + createPortalRunDeliverablesValidator, +} from './portal-gateway-services-bootstrap.mjs'; + +function createSetup(overrides = {}) { + const calls = []; + const pool = { id: 'pool' }; + const userAuth = { id: 'user-auth' }; + const sessionAccess = { id: 'session-access' }; + const llmProviderService = { id: 'llm' }; + const memoryV2 = { + async observePersonalMemory(options) { + calls.push(['observe-memory', options]); + }, + }; + const tkmindProxy = { id: 'proxy' }; + const toolGateway = { id: 'tool-gateway' }; + const agentRunGateway = { id: 'agent-run-gateway' }; + let proxyOptions; + let toolOptions; + let agentOptions; + let validatorOptions; + const options = { + pool, + h5Root: '/app', + env: { + MEMIND_AGENT_RUN_AUTODISPATCH: 'yes', + MEMIND_AGENT_RUN_QUEUE_CONCURRENCY: '3', + MEMIND_AGENT_RUN_TIMEOUT_MS: '9000', + }, + apiTarget: 'http://primary', + apiTargets: ['http://primary', 'http://secondary'], + apiSecret: 'secret', + userAuth, + sessionAccess, + sessionStreamStore: { id: 'stream-store' }, + llmProviderService, + subscriptionService: { id: 'subscription' }, + sessionSnapshotService: { id: 'snapshot' }, + conversationMemoryService: { + id: 'conversation-memory', + }, + memoryV2, + mindSpaceAssets: { + async readAsset(userId, assetId) { + calls.push(['read-asset', userId, assetId]); + return { + asset: { mimeType: 'image/png' }, + path: '/assets/image.png', + }; + }, + }, + directChatService: { id: 'direct-chat' }, + chatIntentRouter: { id: 'intent-router' }, + async syncUserGeneratedPages(userId, options) { + calls.push(['sync-pages', userId, options]); + return { synced: true }; + }, + isSessionPageDeliveryActive(sessionId) { + calls.push(['delivery-active', sessionId]); + return sessionId === 'busy-session'; + }, + createTkmindProxyFn(receivedOptions) { + calls.push(['proxy']); + proxyOptions = receivedOptions; + return tkmindProxy; + }, + createToolGatewayFn(receivedOptions) { + calls.push(['tool-gateway']); + toolOptions = receivedOptions; + return toolGateway; + }, + createAgentRunGatewayFn(receivedOptions) { + calls.push(['agent-run-gateway']); + agentOptions = receivedOptions; + return agentRunGateway; + }, + createRunDeliverablesValidatorFn(receivedOptions) { + calls.push(['validator']); + validatorOptions = receivedOptions; + return async () => ({ errors: [] }); + }, + async readAssetFileFn(assetPath) { + calls.push(['read-asset-file', assetPath]); + return Buffer.from('image'); + }, + ...overrides, + }; + return { + calls, + options, + pool, + userAuth, + sessionAccess, + llmProviderService, + memoryV2, + tkmindProxy, + toolGateway, + agentRunGateway, + getCaptured() { + return { + proxyOptions, + toolOptions, + agentOptions, + validatorOptions, + }; + }, + }; +} + +test('requires the gateway dependencies', () => { + assert.throws( + () => bootstrapPortalGatewayServices(), + /requires gateway dependencies/, + ); +}); + +test('preserves Proxy, Tool, and Agent gateway wiring', () => { + const setup = createSetup(); + const result = bootstrapPortalGatewayServices( + setup.options, + ); + const captured = setup.getCaptured(); + + assert.deepEqual( + setup.calls.map(([name]) => name), + ['proxy', 'tool-gateway', 'validator', 'agent-run-gateway'], + ); + assert.equal(result.tkmindProxy, setup.tkmindProxy); + assert.equal(result.toolGateway, setup.toolGateway); + assert.equal( + result.agentRunGateway, + setup.agentRunGateway, + ); + assert.equal(captured.proxyOptions.userAuth, setup.userAuth); + assert.equal( + captured.proxyOptions.sessionAccess, + setup.sessionAccess, + ); + assert.equal( + captured.proxyOptions.llmProviderService, + setup.llmProviderService, + ); + assert.deepEqual(captured.toolOptions, { + llmProviderService: setup.llmProviderService, + }); + assert.deepEqual(captured.validatorOptions, { + h5Root: '/app', + }); + assert.equal(captured.agentOptions.tkmindProxy, setup.tkmindProxy); + assert.equal(captured.agentOptions.toolGateway, setup.toolGateway); + assert.equal(captured.agentOptions.autoDispatch, true); + assert.equal(captured.agentOptions.maxConcurrentRuns, 3); + assert.equal(captured.agentOptions.runTimeoutMs, 9000); +}); + +test('preserves local asset reads and optional asset absence', async () => { + const setup = createSetup(); + bootstrapPortalGatewayServices(setup.options); + const { proxyOptions } = setup.getCaptured(); + + assert.deepEqual( + await proxyOptions.localFetchAsset('user-1', 'asset-1'), + { + buffer: Buffer.from('image'), + mimeType: 'image/png', + }, + ); + assert.deepEqual( + setup.calls.slice(-2), + [ + ['read-asset', 'user-1', 'asset-1'], + ['read-asset-file', '/assets/image.png'], + ], + ); + + const withoutAssets = createSetup({ + mindSpaceAssets: null, + }); + bootstrapPortalGatewayServices(withoutAssets.options); + assert.equal( + withoutAssets.getCaptured().proxyOptions.localFetchAsset, + null, + ); +}); + +test('preserves memory, page sync, and busy callbacks', async () => { + const setup = createSetup(); + bootstrapPortalGatewayServices(setup.options); + const { agentOptions } = setup.getCaptured(); + + await agentOptions.observePersonalMemoryOnSuccess({ + userId: 'user-1', + sessionId: 'session-1', + userMessage: { role: 'user', content: 'remember' }, + }); + assert.deepEqual(setup.calls.at(-1), [ + 'observe-memory', + { + userId: 'user-1', + sessionId: 'session-1', + messages: [ + { role: 'user', content: 'remember' }, + ], + }, + ]); + assert.deepEqual( + await agentOptions.syncUserPagesOnSuccess({ + userId: 'user-1', + sessionId: 'session-1', + runStartedAtMs: 123, + }), + { synced: true }, + ); + assert.equal( + agentOptions.isSessionExternallyBusy({ + sessionId: 'busy-session', + }), + true, + ); +}); + +test('keeps memory observation optional and parses disabled dispatch', async () => { + const setup = createSetup({ + env: { + MEMIND_AGENT_RUN_AUTODISPATCH: 'off', + }, + memoryV2: {}, + }); + bootstrapPortalGatewayServices(setup.options); + const { agentOptions } = setup.getCaptured(); + + await agentOptions.observePersonalMemoryOnSuccess({ + userId: 'user-1', + sessionId: 'session-1', + userMessage: 'hello', + }); + assert.equal(agentOptions.autoDispatch, false); + assert.equal(agentOptions.maxConcurrentRuns, 1); + assert.equal(agentOptions.runTimeoutMs, 15 * 60 * 1000); +}); + +test('validates Page Data issues, policy grants, and browser storage', async () => { + const policyChecks = []; + const validator = createPortalRunDeliverablesValidator({ + h5Root: '/app', + resolveMindSpaceUserPublishDirFn: () => + '/publish/user-1', + normalizeWorkspaceRelativePathFn: (value) => value, + resolvePathFn: (...parts) => parts.join('/').replaceAll('//', '/'), + pathSeparator: '/', + existsSyncFn: () => true, + readFileSyncFn: () => 'page data', + evaluatePageDataHtmlContentFn: () => ({ + usesPageDataApi: true, + issues: ['dataset_binding_missing'], + }), + readPageAccessPolicyFn: () => ({ id: 'policy' }), + detectPageDataDatasetUsageFromHtmlFn: () => [ + [ + 'orders', + { read: true, insert: true }, + ], + ], + policyAllowsActionFn(policy, dataset, action) { + policyChecks.push([policy, dataset, action]); + return action === 'read'; + }, + scanWorkspaceFilesForProhibitedBrowserStorageFn: + (options) => { + assert.deepEqual(options, { + publishDir: '/publish/user-1', + relativePaths: ['public/orders.html'], + }); + return [ + { + relativePath: 'public/orders.html', + apis: ['localStorage', 'indexedDB'], + }, + ]; + }, + }); + + const result = await validator({ + userId: 'user-1', + deliverables: { + pages: [ + { + pageId: 'page-1', + workspaceRelativePath: 'public/orders.html', + }, + ], + }, + }); + + assert.deepEqual(policyChecks, [ + [{ id: 'policy' }, 'orders', 'read'], + [{ id: 'policy' }, 'orders', 'insert'], + ]); + assert.deepEqual( + result.errors.map(({ code }) => code), + [ + 'dataset_binding_missing', + 'page_data_policy_action_missing', + 'browser_storage_forbidden', + ], + ); +}); + +test('skips non-public, missing, and path-escaping Page Data files', async () => { + const readPaths = []; + const validator = createPortalRunDeliverablesValidator({ + h5Root: '/app', + resolveMindSpaceUserPublishDirFn: () => + '/publish/user-1', + normalizeWorkspaceRelativePathFn: (value) => value, + resolvePathFn: (...parts) => { + const joined = parts.join('/'); + if (joined.includes('escape')) return '/outside/file.html'; + return joined.replaceAll('//', '/'); + }, + pathSeparator: '/', + existsSyncFn: (filePath) => + !filePath.includes('missing'), + readFileSyncFn(filePath) { + readPaths.push(filePath); + return ''; + }, + evaluatePageDataHtmlContentFn: () => ({ + usesPageDataApi: false, + issues: [], + }), + scanWorkspaceFilesForProhibitedBrowserStorageFn: + () => [], + }); + + const result = await validator({ + userId: 'user-1', + deliverables: { + pages: [ + { workspaceRelativePath: 'private/a.html' }, + { workspaceRelativePath: 'public/missing.html' }, + { workspaceRelativePath: 'public/escape.html' }, + { workspaceRelativePath: 'public/valid.html' }, + ], + }, + }); + + assert.deepEqual(readPaths, [ + '/publish/user-1/public/valid.html', + ]); + assert.deepEqual(result, { errors: [] }); +}); diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs new file mode 100644 index 0000000..013020e --- /dev/null +++ b/server/portal-integration-services-bootstrap.mjs @@ -0,0 +1,280 @@ +import { + resolveAnalyticsOwnerLabel, + resolveAnalyticsOwnerSegment, + sendMindSpaceAnalyticsEvent, +} from '../mindspace-analytics.mjs'; +import { createPageEditSessionService } from '../mindspace-page-edit-session.mjs'; +import { resolveMindSpaceRuntimeConfig } from '../mindspace-runtime-config.mjs'; +import { createNotificationDispatcher } from '../notification-dispatcher.mjs'; +import { startScheduleReminderWorker } from '../schedule-reminder-worker.mjs'; +import { loadWechatMpModule } from '../wechat-mp-loader.mjs'; + +export async function bootstrapPortalIntegrationServices({ + pool, + h5Root, + env = process.env, + usersRoot, + wechatMpConfig, + authPool, + userAuth, + sessionAccess, + tkmindProxy, + scheduleService, + wechatScheduleLlmConfigService, + llmProviderService, + chatIntentRouter, + sessionSnapshotService, + mindSpaceAnalyticsConfig, + subscriptionService, + apiTarget, + apiSecret, + mindSpacePages, + mindSpacePageLiveEdit, + logger = console, + loadWechatMpModuleFn = loadWechatMpModule, + resolveMindSpaceRuntimeConfigFn = + resolveMindSpaceRuntimeConfig, + resolveAnalyticsOwnerSegmentFn = + resolveAnalyticsOwnerSegment, + resolveAnalyticsOwnerLabelFn = + resolveAnalyticsOwnerLabel, + sendMindSpaceAnalyticsEventFn = + sendMindSpaceAnalyticsEvent, + createNotificationDispatcherFn = + createNotificationDispatcher, + startScheduleReminderWorkerFn = + startScheduleReminderWorker, + createPageEditSessionServiceFn = + createPageEditSessionService, + setIntervalFn = setInterval, +} = {}) { + if ( + !pool || + !h5Root || + !usersRoot || + !userAuth || + !sessionAccess || + !tkmindProxy || + !llmProviderService + ) { + throw new Error( + 'bootstrapPortalIntegrationServices requires Portal integration dependencies', + ); + } + + const wechatMp = await loadWechatMpModuleFn(h5Root); + const wechatMpService = + wechatMp.createWechatMpService({ + config: wechatMpConfig, + userAuth, + sessionAccess, + pageDataFinishGuard: authPool + ? { + pool: authPool, + h5Root, + storageRoot: + resolveMindSpaceRuntimeConfigFn( + h5Root, + env, + ).storageRoot, + } + : null, + apiFetch: tkmindProxy.apiFetch, + startAgentSession: ({ + userId, + workingDir, + sessionPolicy, + }) => + tkmindProxy.startSessionForUser(userId, { + workingDir, + sessionPolicy, + }), + sessionApiFetch: async ( + sessionId, + pathname, + init, + ) => { + const target = + await tkmindProxy.resolveTarget(sessionId); + return tkmindProxy.apiFetchTo( + target, + pathname, + init, + ); + }, + submitSessionReply: ({ + userId, + sessionId, + requestId, + userMessage, + options, + }) => + tkmindProxy.submitSessionReplyForUser( + userId, + sessionId, + requestId, + userMessage, + options, + ), + scheduleService: + env.H5_SCHEDULE_ENABLED === '1' + ? scheduleService + : null, + wechatScheduleLlmConfigService, + llmProviderService, + chatIntentRouter, + sessionIntentClassifier: ({ text }) => + chatIntentRouter?.classifySessionAction({ + text, + }), + onPageGenerated: async ({ + userId, + sessionId, + artifacts = [], + }) => { + for (const artifact of artifacts) { + void sendMindSpaceAnalyticsEventFn({ + config: mindSpaceAnalyticsConfig, + eventName: 'page_generated', + ownerId: userId, + ownerSegment: + resolveAnalyticsOwnerSegmentFn( + (await userAuth + ?.getUserById(userId) + .catch(() => null)) ?? {}, + ), + ownerLabel: resolveAnalyticsOwnerLabelFn( + (await userAuth + ?.getUserById(userId) + .catch(() => null)) ?? {}, + ), + pageId: artifact.relativePath, + publicationId: sessionId, + agentRunId: sessionId, + channel: 'wechat_mp', + url: + artifact.url || + artifact.relativePath || + '/', + }); + } + }, + applySessionLlmProvider: (sessionId) => + tkmindProxy.applySessionLlmProvider(sessionId), + refreshSessionSnapshot: + sessionSnapshotService?.isEnabled() + ? (sessionId, userId) => + sessionSnapshotService.refresh( + sessionId, + userId, + async (pathname, init) => { + const target = + await tkmindProxy.resolveTarget( + sessionId, + ); + return tkmindProxy.apiFetchTo( + target, + pathname, + init, + ); + }, + ) + : null, + }); + + const notificationDispatcher = + createNotificationDispatcherFn({ + sendWechatTextToUser: wechatMpService?.enabled + ? (userId, text) => + wechatMpService.sendTextToUser( + userId, + text, + ) + : null, + }); + userAuth.setRechargeNotifier( + async ({ userId, title, body, dedupeKey }) => { + await notificationDispatcher.sendRechargeSuccess({ + userId, + title, + body, + dedupeKey, + }); + }, + ); + + let scheduleReminderWorker = null; + if ( + env.H5_REMINDER_WORKER_ENABLED === '1' && + wechatMpService?.enabled && + scheduleService + ) { + scheduleReminderWorker = + startScheduleReminderWorkerFn({ + scheduleService, + notificationDispatcher, + }); + logger.log('Schedule reminder worker enabled'); + } + + let subscriptionExpiryTimer = null; + if (subscriptionService) { + subscriptionExpiryTimer = setIntervalFn( + async () => { + try { + const { renewed, failed } = + await subscriptionService.processAutoRenewals(); + if (renewed > 0) { + logger.log( + `Auto-renewed ${renewed} subscription(s)`, + ); + } + if (failed > 0) { + logger.log( + `Auto-renew failed for ${failed} subscription(s) (balance insufficient)`, + ); + } + const expired = + await subscriptionService.expireStaleSubscriptions(); + if (expired > 0) { + logger.log( + `Expired ${expired} stale subscription(s)`, + ); + } + } catch (error) { + logger.warn( + 'Subscription expiry check failed:', + error, + ); + } + }, + 60 * 60 * 1000, + ); + subscriptionExpiryTimer.unref?.(); + } + + const mindSpacePageEditSession = + createPageEditSessionServiceFn({ + apiTarget, + apiSecret, + userAuth, + sessionAccess, + pageService: mindSpacePages, + pageLiveEdit: mindSpacePageLiveEdit, + llmProviderService, + }); + if (wechatMpService?.enabled) { + logger.log('WeChat MP webhook enabled'); + } + logger.log( + `User auth enabled (MySQL), workspace root: ${usersRoot}`, + ); + + return { + wechatMpService, + notificationDispatcher, + scheduleReminderWorker, + subscriptionExpiryTimer, + mindSpacePageEditSession, + }; +} diff --git a/server/portal-integration-services-bootstrap.test.mjs b/server/portal-integration-services-bootstrap.test.mjs new file mode 100644 index 0000000..f0f7458 --- /dev/null +++ b/server/portal-integration-services-bootstrap.test.mjs @@ -0,0 +1,480 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { bootstrapPortalIntegrationServices } from './portal-integration-services-bootstrap.mjs'; + +function createSetup(overrides = {}) { + const calls = []; + const pool = { id: 'pool' }; + const userAuth = { + async getUserById(userId) { + calls.push(['get-user', userId]); + return { id: userId, username: 'John' }; + }, + setRechargeNotifier(notifier) { + calls.push(['set-recharge-notifier']); + this.rechargeNotifier = notifier; + }, + }; + const sessionAccess = { id: 'session-access' }; + const tkmindProxy = { + apiFetch: { id: 'api-fetch' }, + startSessionForUser(userId, options) { + calls.push(['start-session', userId, options]); + return { sessionId: 'session-1' }; + }, + async resolveTarget(sessionId) { + calls.push(['resolve-target', sessionId]); + return 'http://target'; + }, + apiFetchTo(target, pathname, init) { + calls.push([ + 'api-fetch-to', + target, + pathname, + init, + ]); + return { ok: true }; + }, + submitSessionReplyForUser(...args) { + calls.push(['submit-reply', ...args]); + return { accepted: true }; + }, + applySessionLlmProvider(sessionId) { + calls.push(['apply-provider', sessionId]); + return { applied: true }; + }, + }; + const wechatMpService = { + enabled: true, + sendTextToUser(userId, text) { + calls.push(['send-wechat-text', userId, text]); + return { sent: true }; + }, + }; + const notificationDispatcher = { + async sendRechargeSuccess(options) { + calls.push(['recharge-success', options]); + }, + }; + const scheduleService = { id: 'schedule' }; + const sessionSnapshotService = { + isEnabled() { + return true; + }, + refresh(...args) { + calls.push(['snapshot-refresh', ...args]); + return { refreshed: true }; + }, + }; + let wechatOptions; + let notificationOptions; + let reminderOptions; + let pageEditOptions; + let timerCallback; + let timerDelay; + let unrefCount = 0; + const subscriptionService = { + async processAutoRenewals() { + calls.push(['auto-renew']); + return { renewed: 2, failed: 1 }; + }, + async expireStaleSubscriptions() { + calls.push(['expire-subscriptions']); + return 3; + }, + }; + const options = { + pool, + h5Root: '/app', + env: { + H5_SCHEDULE_ENABLED: '1', + H5_REMINDER_WORKER_ENABLED: '1', + }, + usersRoot: '/users', + wechatMpConfig: { enabled: true }, + authPool: pool, + userAuth, + sessionAccess, + tkmindProxy, + scheduleService, + wechatScheduleLlmConfigService: { + id: 'schedule-llm', + }, + llmProviderService: { id: 'llm' }, + chatIntentRouter: { + classifySessionAction(options) { + calls.push(['classify-session', options]); + return { action: 'chat' }; + }, + }, + sessionSnapshotService, + mindSpaceAnalyticsConfig: { enabled: true }, + subscriptionService, + apiTarget: 'http://api', + apiSecret: 'secret', + mindSpacePages: { id: 'pages' }, + mindSpacePageLiveEdit: { id: 'live-edit' }, + logger: { + log(...args) { + calls.push(['log', ...args]); + }, + warn(...args) { + calls.push(['warn', ...args]); + }, + }, + async loadWechatMpModuleFn(h5Root) { + calls.push(['load-wechat', h5Root]); + return { + createWechatMpService(receivedOptions) { + calls.push(['create-wechat']); + wechatOptions = receivedOptions; + return wechatMpService; + }, + }; + }, + resolveMindSpaceRuntimeConfigFn(h5Root, env) { + calls.push(['runtime-config', h5Root, env]); + return { storageRoot: '/storage' }; + }, + resolveAnalyticsOwnerSegmentFn(user) { + calls.push(['owner-segment', user]); + return 'segment'; + }, + resolveAnalyticsOwnerLabelFn(user) { + calls.push(['owner-label', user]); + return 'label'; + }, + sendMindSpaceAnalyticsEventFn(event) { + calls.push(['analytics', event]); + return Promise.resolve(); + }, + createNotificationDispatcherFn(receivedOptions) { + calls.push(['notification-dispatcher']); + notificationOptions = receivedOptions; + return notificationDispatcher; + }, + startScheduleReminderWorkerFn(receivedOptions) { + calls.push(['reminder-worker']); + reminderOptions = receivedOptions; + return { id: 'reminder-worker' }; + }, + createPageEditSessionServiceFn(receivedOptions) { + calls.push(['page-edit']); + pageEditOptions = receivedOptions; + return { id: 'page-edit' }; + }, + setIntervalFn(callback, delay) { + calls.push(['set-interval']); + timerCallback = callback; + timerDelay = delay; + return { + unref() { + unrefCount += 1; + calls.push(['timer-unref']); + }, + }; + }, + ...overrides, + }; + + return { + calls, + options, + pool, + userAuth, + sessionAccess, + tkmindProxy, + wechatMpService, + notificationDispatcher, + scheduleService, + sessionSnapshotService, + subscriptionService, + getCaptured() { + return { + wechatOptions, + notificationOptions, + reminderOptions, + pageEditOptions, + timerCallback, + timerDelay, + unrefCount, + }; + }, + }; +} + +test('requires Portal integration dependencies', async () => { + await assert.rejects( + bootstrapPortalIntegrationServices(), + /requires Portal integration dependencies/, + ); +}); + +test('preserves WeChat service configuration and proxy callbacks', async () => { + const setup = createSetup(); + await bootstrapPortalIntegrationServices( + setup.options, + ); + const { wechatOptions } = setup.getCaptured(); + + assert.deepEqual(wechatOptions.pageDataFinishGuard, { + pool: setup.pool, + h5Root: '/app', + storageRoot: '/storage', + }); + assert.equal( + wechatOptions.apiFetch, + setup.tkmindProxy.apiFetch, + ); + assert.equal( + wechatOptions.scheduleService, + setup.scheduleService, + ); + assert.deepEqual( + wechatOptions.startAgentSession({ + userId: 'user-1', + workingDir: '/workspace', + sessionPolicy: { sandboxed: true }, + }), + { sessionId: 'session-1' }, + ); + assert.deepEqual( + await wechatOptions.sessionApiFetch( + 'session-1', + '/status', + { method: 'GET' }, + ), + { ok: true }, + ); + assert.deepEqual( + wechatOptions.submitSessionReply({ + userId: 'user-1', + sessionId: 'session-1', + requestId: 'request-1', + userMessage: 'hello', + options: { deep: true }, + }), + { accepted: true }, + ); + assert.deepEqual( + wechatOptions.sessionIntentClassifier({ + text: 'hello', + }), + { action: 'chat' }, + ); + assert.deepEqual( + wechatOptions.applySessionLlmProvider('session-1'), + { applied: true }, + ); +}); + +test('preserves snapshot refresh through the resolved session target', async () => { + const setup = createSetup(); + await bootstrapPortalIntegrationServices( + setup.options, + ); + const { wechatOptions } = setup.getCaptured(); + + assert.equal( + typeof wechatOptions.refreshSessionSnapshot, + 'function', + ); + assert.deepEqual( + wechatOptions.refreshSessionSnapshot( + 'session-1', + 'user-1', + ), + { refreshed: true }, + ); + const refreshCall = setup.calls.find( + ([name]) => name === 'snapshot-refresh', + ); + const fetchSnapshot = refreshCall[3]; + assert.deepEqual( + await fetchSnapshot('/snapshot', { + method: 'GET', + }), + { ok: true }, + ); + assert.ok( + setup.calls.some( + ([name, sessionId]) => + name === 'resolve-target' && + sessionId === 'session-1', + ), + ); +}); + +test('preserves generated-page analytics projection', async () => { + const setup = createSetup(); + await bootstrapPortalIntegrationServices( + setup.options, + ); + const { wechatOptions } = setup.getCaptured(); + + await wechatOptions.onPageGenerated({ + userId: 'user-1', + sessionId: 'session-1', + artifacts: [ + { + relativePath: 'public/page.html', + url: 'https://example/page', + }, + ], + }); + + assert.equal( + setup.calls.filter(([name]) => name === 'get-user') + .length, + 2, + ); + const analyticsCall = setup.calls.find( + ([name]) => name === 'analytics', + ); + assert.deepEqual(analyticsCall[1], { + config: { enabled: true }, + eventName: 'page_generated', + ownerId: 'user-1', + ownerSegment: 'segment', + ownerLabel: 'label', + pageId: 'public/page.html', + publicationId: 'session-1', + agentRunId: 'session-1', + channel: 'wechat_mp', + url: 'https://example/page', + }); +}); + +test('preserves notification, recharge, reminder, and page-edit wiring', async () => { + const setup = createSetup(); + const result = + await bootstrapPortalIntegrationServices( + setup.options, + ); + const captured = setup.getCaptured(); + + assert.equal( + captured.notificationOptions.sendWechatTextToUser( + 'user-1', + 'hello', + ).sent, + true, + ); + await setup.userAuth.rechargeNotifier({ + userId: 'user-1', + title: 'Recharge', + body: 'Success', + dedupeKey: 'dedupe-1', + }); + assert.deepEqual(captured.reminderOptions, { + scheduleService: setup.scheduleService, + notificationDispatcher: + setup.notificationDispatcher, + }); + assert.equal(result.scheduleReminderWorker.id, 'reminder-worker'); + assert.deepEqual(captured.pageEditOptions, { + apiTarget: 'http://api', + apiSecret: 'secret', + userAuth: setup.userAuth, + sessionAccess: setup.sessionAccess, + pageService: setup.options.mindSpacePages, + pageLiveEdit: setup.options.mindSpacePageLiveEdit, + llmProviderService: + setup.options.llmProviderService, + }); +}); + +test('preserves subscription timer work, logs, and unref', async () => { + const setup = createSetup(); + await bootstrapPortalIntegrationServices( + setup.options, + ); + const captured = setup.getCaptured(); + + assert.equal(captured.timerDelay, 60 * 60 * 1000); + assert.equal(captured.unrefCount, 1); + await captured.timerCallback(); + assert.ok( + setup.calls.some( + ([name, message]) => + name === 'log' && + message === 'Auto-renewed 2 subscription(s)', + ), + ); + assert.ok( + setup.calls.some( + ([name, message]) => + name === 'log' && + message === + 'Auto-renew failed for 1 subscription(s) (balance insufficient)', + ), + ); + assert.ok( + setup.calls.some( + ([name, message]) => + name === 'log' && + message === 'Expired 3 stale subscription(s)', + ), + ); +}); + +test('keeps optional integrations disabled and contains timer failures', async () => { + const setup = createSetup({ + env: {}, + authPool: null, + scheduleService: null, + subscriptionService: { + async processAutoRenewals() { + throw new Error('renew failed'); + }, + async expireStaleSubscriptions() { + return 0; + }, + }, + sessionSnapshotService: { + isEnabled: () => false, + }, + async loadWechatMpModuleFn() { + return { + createWechatMpService(options) { + setup.disabledWechatOptions = options; + return { enabled: false }; + }, + }; + }, + }); + + const result = + await bootstrapPortalIntegrationServices( + setup.options, + ); + const captured = setup.getCaptured(); + await captured.timerCallback(); + + assert.equal( + setup.disabledWechatOptions.pageDataFinishGuard, + null, + ); + assert.equal( + setup.disabledWechatOptions.scheduleService, + null, + ); + assert.equal( + setup.disabledWechatOptions.refreshSessionSnapshot, + null, + ); + assert.equal(result.scheduleReminderWorker, null); + assert.equal( + captured.notificationOptions.sendWechatTextToUser, + null, + ); + assert.ok( + setup.calls.some( + ([name, message, error]) => + name === 'warn' && + message === + 'Subscription expiry check failed:' && + error?.message === 'renew failed', + ), + ); +}); diff --git a/server/portal-memory-session-services-bootstrap.mjs b/server/portal-memory-session-services-bootstrap.mjs new file mode 100644 index 0000000..a6f8ed8 --- /dev/null +++ b/server/portal-memory-session-services-bootstrap.mjs @@ -0,0 +1,145 @@ +import { createAgentCodeRunAdminConfigService } from '../agent-code-run-admin-config.mjs'; +import { createManagedChatIntentRouter } from '../chat-intent-router.mjs'; +import { createConversationMemoryService } from '../conversation-memory.mjs'; +import { createDirectChatService } from '../direct-chat-service.mjs'; +import { createEpisodicMemoryService } from '../episodic-memory.mjs'; +import { createMemoryV2AdminConfigService } from '../memory-v2-admin-config.mjs'; +import { createManagedMemoryV2Runtime } from '../memory-v2-runtime.mjs'; +import { createSessionSnapshotService } from '../session-snapshot.mjs'; +import { createSessionStreamStore } from '../session-stream-store.mjs'; +import { isSessionStreamReplayEnabled } from '../session-stream.mjs'; +import { createSkillRuntimeAdminConfigService } from '../skill-runtime-admin-config.mjs'; +import { createWechatScheduleLlmConfigService } from '../wechat-schedule-llm-config.mjs'; + +export async function bootstrapPortalMemorySessionServices({ + pool, + h5Root, + env = process.env, + llmProviderService, + userAuth, + sessionAccess, + logger = console, + createMemoryV2AdminConfigServiceFn = + createMemoryV2AdminConfigService, + createSkillRuntimeAdminConfigServiceFn = + createSkillRuntimeAdminConfigService, + createAgentCodeRunAdminConfigServiceFn = + createAgentCodeRunAdminConfigService, + createWechatScheduleLlmConfigServiceFn = + createWechatScheduleLlmConfigService, + createConversationMemoryServiceFn = + createConversationMemoryService, + createManagedMemoryV2RuntimeFn = + createManagedMemoryV2Runtime, + createEpisodicMemoryServiceFn = + createEpisodicMemoryService, + createSessionSnapshotServiceFn = + createSessionSnapshotService, + isSessionStreamReplayEnabledFn = + isSessionStreamReplayEnabled, + createSessionStreamStoreFn = + createSessionStreamStore, + createDirectChatServiceFn = createDirectChatService, + createManagedChatIntentRouterFn = + createManagedChatIntentRouter, +} = {}) { + if ( + !pool || + !h5Root || + !llmProviderService || + !userAuth || + !sessionAccess + ) { + throw new Error( + 'bootstrapPortalMemorySessionServices requires memory and session dependencies', + ); + } + + const memoryV2ConfigService = + createMemoryV2AdminConfigServiceFn(pool); + const skillRuntimeConfigService = + createSkillRuntimeAdminConfigServiceFn(pool, { + h5Root, + }); + const agentCodeRunPolicyService = + createAgentCodeRunAdminConfigServiceFn(pool, { env }); + const wechatScheduleLlmConfigService = + createWechatScheduleLlmConfigServiceFn(pool); + + const getEffectiveEnv = async () => { + const state = await memoryV2ConfigService + .getRuntimeState() + .catch(() => null); + return { + ...env, + ...(state?.overrides ?? {}), + }; + }; + const conversationMemoryService = + createConversationMemoryServiceFn(pool, { + llmProviderService, + getEffectiveEnv, + }); + const memoryV2 = + await createManagedMemoryV2RuntimeFn({ + legacyMemoryService: conversationMemoryService, + configService: memoryV2ConfigService, + mysqlPool: pool, + }); + const episodicMemoryService = + createEpisodicMemoryServiceFn(pool, { + getEffectiveEnv, + logger, + }); + await episodicMemoryService + .ensureSchema() + .catch((error) => { + logger.warn( + '[episodic-memory] schema setup degraded; snapshot fallback remains available:', + error instanceof Error ? error.message : error, + ); + }); + + const sessionSnapshotService = + createSessionSnapshotServiceFn(pool, { + conversationMemoryService, + memoryV2, + episodicMemoryService, + }); + const sessionStreamStore = + isSessionStreamReplayEnabledFn() + ? createSessionStreamStoreFn({ pool }) + : null; + const directChatService = createDirectChatServiceFn({ + userAuth, + sessionAccess, + llmProviderService, + sessionSnapshotService, + memoryV2, + conversationMemoryService, + episodicMemoryService, + }); + const chatIntentRouter = + createManagedChatIntentRouterFn({ + llmProviderService, + memoryV2, + conversationMemoryService, + episodicMemoryService, + configService: memoryV2ConfigService, + }); + + return { + memoryV2ConfigService, + skillRuntimeConfigService, + agentCodeRunPolicyService, + wechatScheduleLlmConfigService, + conversationMemoryService, + memoryV2, + episodicMemoryService, + sessionSnapshotService, + sessionStreamStore, + directChatService, + chatIntentRouter, + getEffectiveEnv, + }; +} diff --git a/server/portal-memory-session-services-bootstrap.test.mjs b/server/portal-memory-session-services-bootstrap.test.mjs new file mode 100644 index 0000000..abb3555 --- /dev/null +++ b/server/portal-memory-session-services-bootstrap.test.mjs @@ -0,0 +1,348 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { bootstrapPortalMemorySessionServices } from './portal-memory-session-services-bootstrap.mjs'; + +function createSetup(overrides = {}) { + const calls = []; + const pool = { id: 'pool' }; + const llmProviderService = { id: 'llm' }; + const userAuth = { id: 'user-auth' }; + const sessionAccess = { id: 'session-access' }; + const memoryV2ConfigService = { + async getRuntimeState() { + calls.push(['runtime-state']); + return { + overrides: { + MEMORY_BACKEND: 'pgvector', + SHARED: 'override', + }, + }; + }, + }; + const conversationMemoryService = { + id: 'conversation-memory', + }; + const memoryV2 = { id: 'memory-v2' }; + const episodicMemoryService = { + id: 'episodic', + async ensureSchema() { + calls.push(['episodic-schema']); + }, + }; + const sessionSnapshotService = { id: 'snapshot' }; + const sessionStreamStore = { id: 'stream-store' }; + const directChatService = { id: 'direct-chat' }; + const chatIntentRouter = { id: 'intent-router' }; + let skillRuntimeOptions; + let agentCodeRunOptions; + let conversationOptions; + let memoryV2Options; + let episodicOptions; + let snapshotOptions; + let streamStoreOptions; + let directChatOptions; + let routerOptions; + + const options = { + pool, + h5Root: '/app', + env: { + BASE_ONLY: 'base', + SHARED: 'base', + }, + llmProviderService, + userAuth, + sessionAccess, + logger: { + warn(...args) { + calls.push(['warn', ...args]); + }, + }, + createMemoryV2AdminConfigServiceFn(receivedPool) { + assert.equal(receivedPool, pool); + calls.push(['memory-config']); + return memoryV2ConfigService; + }, + createSkillRuntimeAdminConfigServiceFn( + receivedPool, + receivedOptions, + ) { + assert.equal(receivedPool, pool); + calls.push(['skill-config']); + skillRuntimeOptions = receivedOptions; + return { id: 'skill-config' }; + }, + createAgentCodeRunAdminConfigServiceFn( + receivedPool, + receivedOptions, + ) { + assert.equal(receivedPool, pool); + calls.push(['agent-code-run-config']); + agentCodeRunOptions = receivedOptions; + return { id: 'agent-code-run-config' }; + }, + createWechatScheduleLlmConfigServiceFn( + receivedPool, + ) { + assert.equal(receivedPool, pool); + calls.push(['wechat-schedule-config']); + return { id: 'wechat-schedule-config' }; + }, + createConversationMemoryServiceFn( + receivedPool, + receivedOptions, + ) { + assert.equal(receivedPool, pool); + calls.push(['conversation-memory']); + conversationOptions = receivedOptions; + return conversationMemoryService; + }, + async createManagedMemoryV2RuntimeFn( + receivedOptions, + ) { + calls.push(['memory-runtime']); + memoryV2Options = receivedOptions; + return memoryV2; + }, + createEpisodicMemoryServiceFn( + receivedPool, + receivedOptions, + ) { + assert.equal(receivedPool, pool); + calls.push(['episodic-memory']); + episodicOptions = receivedOptions; + return episodicMemoryService; + }, + createSessionSnapshotServiceFn( + receivedPool, + receivedOptions, + ) { + assert.equal(receivedPool, pool); + calls.push(['snapshot']); + snapshotOptions = receivedOptions; + return sessionSnapshotService; + }, + isSessionStreamReplayEnabledFn() { + calls.push(['replay-enabled']); + return true; + }, + createSessionStreamStoreFn(receivedOptions) { + calls.push(['stream-store']); + streamStoreOptions = receivedOptions; + return sessionStreamStore; + }, + createDirectChatServiceFn(receivedOptions) { + calls.push(['direct-chat']); + directChatOptions = receivedOptions; + return directChatService; + }, + createManagedChatIntentRouterFn( + receivedOptions, + ) { + calls.push(['intent-router']); + routerOptions = receivedOptions; + return chatIntentRouter; + }, + ...overrides, + }; + + return { + calls, + options, + pool, + llmProviderService, + userAuth, + sessionAccess, + memoryV2ConfigService, + conversationMemoryService, + memoryV2, + episodicMemoryService, + sessionSnapshotService, + sessionStreamStore, + getCaptured() { + return { + skillRuntimeOptions, + agentCodeRunOptions, + conversationOptions, + memoryV2Options, + episodicOptions, + snapshotOptions, + streamStoreOptions, + directChatOptions, + routerOptions, + }; + }, + }; +} + +test('requires the Memory and Session dependencies', async () => { + await assert.rejects( + bootstrapPortalMemorySessionServices(), + /requires memory and session dependencies/, + ); +}); + +test('preserves Memory, Session, and chat assembly order', async () => { + const setup = createSetup(); + const result = + await bootstrapPortalMemorySessionServices( + setup.options, + ); + + assert.deepEqual( + setup.calls.map(([name]) => name), + [ + 'memory-config', + 'skill-config', + 'agent-code-run-config', + 'wechat-schedule-config', + 'conversation-memory', + 'memory-runtime', + 'episodic-memory', + 'episodic-schema', + 'snapshot', + 'replay-enabled', + 'stream-store', + 'direct-chat', + 'intent-router', + ], + ); + assert.equal( + result.memoryV2ConfigService, + setup.memoryV2ConfigService, + ); + assert.equal(result.agentCodeRunPolicyService.id, 'agent-code-run-config'); + assert.equal( + result.sessionStreamStore, + setup.sessionStreamStore, + ); + assert.equal(result.directChatService.id, 'direct-chat'); + assert.equal(result.chatIntentRouter.id, 'intent-router'); +}); + +test('preserves configuration and runtime service wiring', async () => { + const setup = createSetup(); + await bootstrapPortalMemorySessionServices( + setup.options, + ); + const captured = setup.getCaptured(); + + assert.deepEqual(captured.skillRuntimeOptions, { + h5Root: '/app', + }); + assert.deepEqual(captured.agentCodeRunOptions, { + env: { + BASE_ONLY: 'base', + SHARED: 'base', + }, + }); + assert.equal( + captured.conversationOptions.llmProviderService, + setup.llmProviderService, + ); + assert.deepEqual(captured.memoryV2Options, { + legacyMemoryService: setup.conversationMemoryService, + configService: setup.memoryV2ConfigService, + mysqlPool: setup.pool, + }); + assert.equal( + captured.episodicOptions.logger, + setup.options.logger, + ); + assert.deepEqual(captured.snapshotOptions, { + conversationMemoryService: + setup.conversationMemoryService, + memoryV2: setup.memoryV2, + episodicMemoryService: setup.episodicMemoryService, + }); + assert.deepEqual(captured.streamStoreOptions, { + pool: setup.pool, + }); +}); + +test('merges runtime overrides and falls back to the base environment', async () => { + const setup = createSetup(); + const result = + await bootstrapPortalMemorySessionServices( + setup.options, + ); + const captured = setup.getCaptured(); + + assert.deepEqual( + await captured.conversationOptions.getEffectiveEnv(), + { + BASE_ONLY: 'base', + SHARED: 'override', + MEMORY_BACKEND: 'pgvector', + }, + ); + assert.equal( + captured.conversationOptions.getEffectiveEnv, + captured.episodicOptions.getEffectiveEnv, + ); + + setup.memoryV2ConfigService.getRuntimeState = + async () => { + throw new Error('config unavailable'); + }; + assert.deepEqual(await result.getEffectiveEnv(), { + BASE_ONLY: 'base', + SHARED: 'base', + }); +}); + +test('preserves Direct Chat and Intent Router dependencies', async () => { + const setup = createSetup(); + await bootstrapPortalMemorySessionServices( + setup.options, + ); + const captured = setup.getCaptured(); + + assert.deepEqual(captured.directChatOptions, { + userAuth: setup.userAuth, + sessionAccess: setup.sessionAccess, + llmProviderService: setup.llmProviderService, + sessionSnapshotService: setup.sessionSnapshotService, + memoryV2: setup.memoryV2, + conversationMemoryService: + setup.conversationMemoryService, + episodicMemoryService: setup.episodicMemoryService, + }); + assert.deepEqual(captured.routerOptions, { + llmProviderService: setup.llmProviderService, + memoryV2: setup.memoryV2, + conversationMemoryService: + setup.conversationMemoryService, + episodicMemoryService: setup.episodicMemoryService, + configService: setup.memoryV2ConfigService, + }); +}); + +test('degrades episodic schema failures and can disable replay storage', async () => { + const setup = createSetup({ + createEpisodicMemoryServiceFn() { + return { + async ensureSchema() { + throw new Error('schema unavailable'); + }, + }; + }, + isSessionStreamReplayEnabledFn: () => false, + }); + + const result = + await bootstrapPortalMemorySessionServices( + setup.options, + ); + + assert.equal(result.sessionStreamStore, null); + assert.ok( + setup.calls.some( + ([name, message, detail]) => + name === 'warn' && + message === + '[episodic-memory] schema setup degraded; snapshot fallback remains available:' && + detail === 'schema unavailable', + ), + ); +}); diff --git a/server/portal-mindspace-agent-operation-routes.mjs b/server/portal-mindspace-agent-operation-routes.mjs new file mode 100644 index 0000000..85fbe06 --- /dev/null +++ b/server/portal-mindspace-agent-operation-routes.mjs @@ -0,0 +1,119 @@ +function assertRouter(api) { + if (!api || typeof api.post !== 'function') { + throw new Error( + 'attachPortalMindSpaceAgentOperationRoutes requires an Express-compatible router', + ); + } +} + +export function attachPortalMindSpaceAgentOperationRoutes( + api, + { + getMindSpacePageLiveEdit = () => null, + getMindSpaceAssetAgent = () => null, + getMindSpaceAudit = () => null, + sendData = (res, _req, data, status = 200) => + res.status(status).json({ data }), + sendError = (res, _req, status, code, message) => + res.status(status).json({ error: { code, message } }), + handleMindSpaceError = (_res, _req, error) => { + throw error; + }, + } = {}, +) { + assertRouter(api); + + api.post('/agent/mindspace_page_patch', async (req, res) => { + const pageLiveEdit = getMindSpacePageLiveEdit(); + if (!pageLiveEdit) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const result = await pageLiveEdit.applyAgentPatch(req.body ?? {}); + return res.json({ + data: { + pageId: result.page.id, + title: result.page.title, + summary: result.page.summary, + versionNo: result.page.versionNo, + updatedAt: result.page.updatedAt, + liveRevision: result.liveRevision, + }, + }); + } catch (error) { + if ( + error?.code === 'forbidden' || + error?.code === 'page_binding_mismatch' + ) { + return sendError(res, req, 403, error.code, error.message); + } + if (error?.code === 'invalid_request') { + return sendError(res, req, 400, error.code, error.message); + } + return handleMindSpaceError(res, req, error); + } + }); + + api.post('/agent/mindspace_asset_delete', async (req, res) => { + const assetAgent = getMindSpaceAssetAgent(); + if (!assetAgent) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const result = await assetAgent.applyAgentDelete(req.body ?? {}); + await getMindSpaceAudit()?.write({ + userId: result.userId ?? null, + action: 'asset.delete', + objectType: 'asset', + objectId: result.assetId, + ip: req.ip, + metadata: { via: 'agent' }, + }); + return sendData(res, req, result); + } catch (error) { + if (error?.code === 'forbidden') { + return sendError(res, req, 403, error.code, error.message); + } + if ( + error?.code === 'invalid_request' || + error?.code === 'confirmation_required' + ) { + return sendError(res, req, 400, error.code, error.message); + } + return handleMindSpaceError(res, req, error); + } + }); + + api.post('/agent/mindspace_asset_download', async (req, res) => { + const assetAgent = getMindSpaceAssetAgent(); + if (!assetAgent) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const result = await assetAgent.applyAgentDownload(req.body ?? {}); + await getMindSpaceAudit()?.write({ + userId: result.userId ?? null, + action: 'asset.download', + objectType: 'asset', + objectId: result.assetId, + ip: req.ip, + metadata: { + via: 'agent', + relativePath: result.relativePath ?? null, + }, + }); + return sendData(res, req, result); + } catch (error) { + if (error?.code === 'forbidden') { + return sendError(res, req, 403, error.code, error.message); + } + if ( + error?.code === 'invalid_request' || + error?.code === 'asset_not_found' + ) { + return sendError(res, req, 400, error.code, error.message); + } + return handleMindSpaceError(res, req, error); + } + }); +} diff --git a/server/portal-mindspace-agent-operation-routes.test.mjs b/server/portal-mindspace-agent-operation-routes.test.mjs new file mode 100644 index 0000000..57712b0 --- /dev/null +++ b/server/portal-mindspace-agent-operation-routes.test.mjs @@ -0,0 +1,288 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalMindSpaceAgentOperationRoutes } from './portal-mindspace-agent-operation-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + post(path, handler) { + routes.set(`POST ${path}`, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +function createRequest(overrides = {}) { + return { + body: {}, + ip: '127.0.0.1', + ...overrides, + }; +} + +function createDependencies(overrides = {}) { + const calls = { + data: [], + errors: [], + mapped: [], + }; + return { + calls, + dependencies: { + sendData(res, req, data, status = 200) { + calls.data.push({ res, req, data, status }); + return res.status(status).json({ data }); + }, + sendError(res, req, status, code, message) { + calls.errors.push({ res, req, status, code, message }); + return res.status(status).json({ error: { code, message } }); + }, + handleMindSpaceError(res, req, error) { + calls.mapped.push({ res, req, error }); + return res.status(418).json({ mapped: error.message }); + }, + ...overrides, + }, + }; +} + +test('MindSpace agent operation module preserves route inventory and order', () => { + const api = createRouterRecorder(); + attachPortalMindSpaceAgentOperationRoutes(api); + assert.deepEqual([...api.routes.keys()], [ + 'POST /agent/mindspace_page_patch', + 'POST /agent/mindspace_asset_delete', + 'POST /agent/mindspace_asset_download', + ]); +}); + +test('page patch preserves payload projection and unavailable response', async () => { + const api = createRouterRecorder(); + const dependencies = createDependencies({ + getMindSpacePageLiveEdit: () => ({ + async applyAgentPatch(body) { + assert.deepEqual(body, { page_id: 'page-1', patch: 'title' }); + return { + page: { + id: 'page-1', + title: 'Updated', + summary: 'Summary', + versionNo: 3, + updatedAt: '2026-07-23T00:00:00Z', + }, + liveRevision: 7, + internal: 'hidden', + }; + }, + }), + }); + attachPortalMindSpaceAgentOperationRoutes(api, dependencies.dependencies); + const res = createResponseRecorder(); + await api.routes.get('POST /agent/mindspace_page_patch')( + createRequest({ body: { page_id: 'page-1', patch: 'title' } }), + res, + ); + assert.deepEqual(res.body, { + data: { + pageId: 'page-1', + title: 'Updated', + summary: 'Summary', + versionNo: 3, + updatedAt: '2026-07-23T00:00:00Z', + liveRevision: 7, + }, + }); + + const unavailableApi = createRouterRecorder(); + attachPortalMindSpaceAgentOperationRoutes(unavailableApi); + const unavailableRes = createResponseRecorder(); + await unavailableApi.routes.get('POST /agent/mindspace_page_patch')( + createRequest(), + unavailableRes, + ); + assert.equal(unavailableRes.statusCode, 503); + assert.deepEqual(unavailableRes.body, { message: 'MindSpace 未启用' }); +}); + +test('page patch preserves expected and fallback error mapping', async () => { + for (const [code, expectedStatus] of [ + ['forbidden', 403], + ['page_binding_mismatch', 403], + ['invalid_request', 400], + ]) { + const api = createRouterRecorder(); + const failure = Object.assign(new Error(code), { code }); + const dependencies = createDependencies({ + getMindSpacePageLiveEdit: () => ({ + async applyAgentPatch() { + throw failure; + }, + }), + }); + attachPortalMindSpaceAgentOperationRoutes(api, dependencies.dependencies); + await api.routes.get('POST /agent/mindspace_page_patch')( + createRequest(), + createResponseRecorder(), + ); + assert.equal(dependencies.calls.errors[0].status, expectedStatus); + assert.equal(dependencies.calls.errors[0].code, code); + } + + const api = createRouterRecorder(); + const failure = new Error('patch failed'); + const dependencies = createDependencies({ + getMindSpacePageLiveEdit: () => ({ + async applyAgentPatch() { + throw failure; + }, + }), + }); + attachPortalMindSpaceAgentOperationRoutes(api, dependencies.dependencies); + await api.routes.get('POST /agent/mindspace_page_patch')( + createRequest(), + createResponseRecorder(), + ); + assert.equal(dependencies.calls.mapped[0].error, failure); +}); + +test('asset delete preserves body, audit, response, and error mapping', async () => { + const audit = []; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + getMindSpaceAssetAgent: () => ({ + async applyAgentDelete(body) { + assert.deepEqual(body, { asset_id: 'asset-1', confirm: true }); + return { userId: 'user-1', assetId: 'asset-1', deleted: true }; + }, + }), + getMindSpaceAudit: () => ({ + async write(entry) { + audit.push(entry); + }, + }), + }); + attachPortalMindSpaceAgentOperationRoutes(api, dependencies.dependencies); + const req = createRequest({ + body: { asset_id: 'asset-1', confirm: true }, + ip: '10.0.0.8', + }); + const res = createResponseRecorder(); + await api.routes.get('POST /agent/mindspace_asset_delete')(req, res); + assert.deepEqual(audit, [{ + userId: 'user-1', + action: 'asset.delete', + objectType: 'asset', + objectId: 'asset-1', + ip: '10.0.0.8', + metadata: { via: 'agent' }, + }]); + assert.deepEqual(dependencies.calls.data[0].data, { + userId: 'user-1', + assetId: 'asset-1', + deleted: true, + }); + + for (const [code, expectedStatus] of [ + ['forbidden', 403], + ['invalid_request', 400], + ['confirmation_required', 400], + ]) { + const failureApi = createRouterRecorder(); + const failureDependencies = createDependencies({ + getMindSpaceAssetAgent: () => ({ + async applyAgentDelete() { + throw Object.assign(new Error(code), { code }); + }, + }), + }); + attachPortalMindSpaceAgentOperationRoutes( + failureApi, + failureDependencies.dependencies, + ); + await failureApi.routes.get('POST /agent/mindspace_asset_delete')( + createRequest(), + createResponseRecorder(), + ); + assert.equal(failureDependencies.calls.errors[0].status, expectedStatus); + } +}); + +test('asset download preserves audit metadata and error mapping', async () => { + const audit = []; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + getMindSpaceAssetAgent: () => ({ + async applyAgentDownload(body) { + assert.deepEqual(body, { asset_id: 'asset-2' }); + return { + userId: 'user-2', + assetId: 'asset-2', + relativePath: 'downloads/report.docx', + }; + }, + }), + getMindSpaceAudit: () => ({ + async write(entry) { + audit.push(entry); + }, + }), + }); + attachPortalMindSpaceAgentOperationRoutes(api, dependencies.dependencies); + await api.routes.get('POST /agent/mindspace_asset_download')( + createRequest({ + body: { asset_id: 'asset-2' }, + ip: '10.0.0.9', + }), + createResponseRecorder(), + ); + assert.deepEqual(audit[0], { + userId: 'user-2', + action: 'asset.download', + objectType: 'asset', + objectId: 'asset-2', + ip: '10.0.0.9', + metadata: { + via: 'agent', + relativePath: 'downloads/report.docx', + }, + }); + + for (const [code, expectedStatus] of [ + ['forbidden', 403], + ['invalid_request', 400], + ['asset_not_found', 400], + ]) { + const failureApi = createRouterRecorder(); + const failureDependencies = createDependencies({ + getMindSpaceAssetAgent: () => ({ + async applyAgentDownload() { + throw Object.assign(new Error(code), { code }); + }, + }), + }); + attachPortalMindSpaceAgentOperationRoutes( + failureApi, + failureDependencies.dependencies, + ); + await failureApi.routes.get('POST /agent/mindspace_asset_download')( + createRequest(), + createResponseRecorder(), + ); + assert.equal(failureDependencies.calls.errors[0].status, expectedStatus); + } +}); diff --git a/server/portal-mindspace-asset-delivery-routes.mjs b/server/portal-mindspace-asset-delivery-routes.mjs new file mode 100644 index 0000000..7e7042a --- /dev/null +++ b/server/portal-mindspace-asset-delivery-routes.mjs @@ -0,0 +1,198 @@ +import { + createDownloadConversationPackageArtifactHandler, + createDownloadConversationPackageManifestHandler, + createGetConversationPackageHandler, +} from '../mindspace-conversation-package-routes.mjs'; + +function assertRouter(api) { + if ( + !api || + typeof api.get !== 'function' || + typeof api.post !== 'function' || + typeof api.put !== 'function' || + typeof api.delete !== 'function' + ) { + throw new Error( + 'attachPortalMindSpaceAssetDeliveryRoutes requires an Express-compatible router', + ); + } +} + +export function attachPortalMindSpaceAssetDeliveryRoutes( + api, + { + rawUploadBody = (_req, _res, next) => next(), + getConversationPackageRegistry = () => null, + getUserAuth = () => null, + getSessionAccess = () => null, + beforeReadManifest, + getMindSpaceAssets = () => null, + ensureMindSpaceEnabled = () => false, + sendData = (res, _req, data, status = 200) => + res.status(status).json({ data }), + handleMindSpaceError = (_res, _req, error) => { + throw error; + }, + } = {}, +) { + assertRouter(api); + + const conversationPackageOptions = { + getRegistry: getConversationPackageRegistry, + getUserAuth, + getSessionAccess, + ensureMindSpaceEnabled, + mindSpaceError: handleMindSpaceError, + }; + + api.get( + '/mindspace/v1/conversation-packages/:sessionId', + createGetConversationPackageHandler({ + ...conversationPackageOptions, + sendData, + beforeReadManifest, + }), + ); + + api.get( + '/mindspace/v1/conversation-packages/:sessionId/manifest.json', + createDownloadConversationPackageManifestHandler({ + ...conversationPackageOptions, + beforeReadManifest, + }), + ); + + api.get( + '/mindspace/v1/conversation-packages/:sessionId/artifacts/:artifactId/download', + createDownloadConversationPackageArtifactHandler( + conversationPackageOptions, + ), + ); + + api.post('/mindspace/v1/uploads', async (req, res) => { + const mindSpaceAssets = getMindSpaceAssets(); + if ( + !mindSpaceAssets || + !ensureMindSpaceEnabled(res, req, { upload: true }) + ) { + return; + } + try { + const upload = await mindSpaceAssets.createUpload(req.currentUser.id, { + categoryId: req.body?.category_id, + filename: req.body?.filename, + sizeBytes: req.body?.size_bytes, + declaredMimeType: req.body?.declared_mime_type, + sourceSessionId: req.body?.session_id, + sourceMessageId: req.body?.message_id, + }); + return sendData(res, req, upload, 201); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.put( + '/mindspace/v1/uploads/:uploadId/content', + rawUploadBody, + async (req, res) => { + const mindSpaceAssets = getMindSpaceAssets(); + if (!mindSpaceAssets) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const result = await mindSpaceAssets.writeUploadContent( + req.currentUser.id, + req.params.uploadId, + req.body, + ); + return res.json({ data: result }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.post('/mindspace/v1/uploads/:uploadId/complete', async (req, res) => { + const mindSpaceAssets = getMindSpaceAssets(); + if (!mindSpaceAssets) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const asset = await mindSpaceAssets.completeUpload( + req.currentUser.id, + req.params.uploadId, + ); + return res.status(201).json({ data: asset }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.post( + '/mindspace/v1/conversation-packages/:sessionId/claim-uploads', + async (req, res) => { + const mindSpaceAssets = getMindSpaceAssets(); + if ( + !mindSpaceAssets || + !ensureMindSpaceEnabled(res, req, { upload: true }) + ) { + return; + } + try { + const result = + await mindSpaceAssets.claimUploadArtifactsForConversation( + req.currentUser.id, + { + sessionId: req.params.sessionId, + messageId: req.body?.message_id, + }, + ); + return sendData(res, req, result); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.delete('/mindspace/v1/uploads/:uploadId', async (req, res) => { + const mindSpaceAssets = getMindSpaceAssets(); + if (!mindSpaceAssets) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const result = await mindSpaceAssets.cancelUpload( + req.currentUser.id, + req.params.uploadId, + ); + return res.json({ data: result }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.get('/mindspace/v1/assets', async (req, res) => { + const mindSpaceAssets = getMindSpaceAssets(); + if (!mindSpaceAssets) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const assets = await mindSpaceAssets.listAssets(req.currentUser.id, { + categoryId: + typeof req.query.category_id === 'string' + ? req.query.category_id + : undefined, + categoryCode: + typeof req.query.category_code === 'string' + ? req.query.category_code + : undefined, + }); + return res.json({ + data: assets, + page: { next_cursor: null, has_more: false }, + }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); +} diff --git a/server/portal-mindspace-asset-delivery-routes.test.mjs b/server/portal-mindspace-asset-delivery-routes.test.mjs new file mode 100644 index 0000000..0d3a018 --- /dev/null +++ b/server/portal-mindspace-asset-delivery-routes.test.mjs @@ -0,0 +1,380 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalMindSpaceAssetDeliveryRoutes } from './portal-mindspace-asset-delivery-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + const register = (method) => (path, ...handlers) => { + routes.set(`${method} ${path}`, handlers); + }; + return { + routes, + get: register('GET'), + post: register('POST'), + put: register('PUT'), + delete: register('DELETE'), + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + headers: {}, + headersSent: false, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + this.headersSent = true; + return this; + }, + set(name, value) { + this.headers[name] = value; + return this; + }, + setHeader(name, value) { + this.headers[name] = value; + }, + send(body) { + this.body = body; + this.headersSent = true; + return this; + }, + }; +} + +function createRequest(overrides = {}) { + return { + body: {}, + query: {}, + params: {}, + currentUser: { id: 'user-1' }, + requestId: 'request-1', + ...overrides, + }; +} + +function createDependencies(overrides = {}) { + const calls = { + data: [], + errors: [], + }; + return { + calls, + dependencies: { + ensureMindSpaceEnabled: () => true, + sendData(res, req, data, status = 200) { + calls.data.push({ res, req, data, status }); + return res.status(status).json({ data }); + }, + handleMindSpaceError(res, req, error) { + calls.errors.push({ res, req, error }); + return res.status(418).json({ mapped: error.message }); + }, + ...overrides, + }, + }; +} + +function routeHandler(api, key, index = 0) { + return api.routes.get(key)[index]; +} + +test('asset delivery module preserves route inventory and upload middleware order', () => { + const api = createRouterRecorder(); + const rawUploadBody = () => {}; + attachPortalMindSpaceAssetDeliveryRoutes(api, { rawUploadBody }); + + assert.deepEqual([...api.routes.keys()], [ + 'GET /mindspace/v1/conversation-packages/:sessionId', + 'GET /mindspace/v1/conversation-packages/:sessionId/manifest.json', + 'GET /mindspace/v1/conversation-packages/:sessionId/artifacts/:artifactId/download', + 'POST /mindspace/v1/uploads', + 'PUT /mindspace/v1/uploads/:uploadId/content', + 'POST /mindspace/v1/uploads/:uploadId/complete', + 'POST /mindspace/v1/conversation-packages/:sessionId/claim-uploads', + 'DELETE /mindspace/v1/uploads/:uploadId', + 'GET /mindspace/v1/assets', + ]); + const contentHandlers = api.routes.get( + 'PUT /mindspace/v1/uploads/:uploadId/content', + ); + assert.equal(contentHandlers.length, 2); + assert.equal(contentHandlers[0], rawUploadBody); +}); + +test('conversation package routes preserve ownership, prepare, manifest, and artifact downloads', async () => { + const prepareCalls = []; + const manifest = { + title: 'Conversation One', + displayFolderName: 'Conversation One', + }; + const artifactBody = Buffer.from('artifact-data'); + const registry = { + async readManifestForSession(input) { + assert.deepEqual(input, { userId: 'user-1', sessionId: 'session-1' }); + return manifest; + }, + async readArtifactObject(input) { + assert.deepEqual(input, { + userId: 'user-1', + sessionId: 'session-1', + artifactId: 'artifact-1', + }); + return { + artifact: { + id: 'artifact-1', + displayName: 'report.txt', + mimeType: 'text/plain', + }, + body: artifactBody, + }; + }, + }; + const api = createRouterRecorder(); + const deps = createDependencies({ + getConversationPackageRegistry: () => registry, + getSessionAccess: () => ({ + validateOwnership: async (userId, sessionId) => + userId === 'user-1' && sessionId === 'session-1', + }), + beforeReadManifest: async (input) => { + prepareCalls.push(input); + }, + }); + attachPortalMindSpaceAssetDeliveryRoutes(api, deps.dependencies); + + const packageReq = createRequest({ params: { sessionId: 'session-1' } }); + const packageRes = createResponseRecorder(); + await routeHandler( + api, + 'GET /mindspace/v1/conversation-packages/:sessionId', + )(packageReq, packageRes); + assert.equal(deps.calls.data[0].data, manifest); + + const manifestReq = createRequest({ params: { sessionId: 'session-1' } }); + const manifestRes = createResponseRecorder(); + await routeHandler( + api, + 'GET /mindspace/v1/conversation-packages/:sessionId/manifest.json', + )(manifestReq, manifestRes); + assert.equal(manifestRes.headers['Content-Type'], 'application/json; charset=utf-8'); + assert.match(manifestRes.headers['Content-Disposition'], /Conversation%20One-manifest\.json/); + assert.match(manifestRes.body, /"title": "Conversation One"/); + + const artifactReq = createRequest({ + params: { sessionId: 'session-1', artifactId: 'artifact-1' }, + }); + const artifactRes = createResponseRecorder(); + await routeHandler( + api, + 'GET /mindspace/v1/conversation-packages/:sessionId/artifacts/:artifactId/download', + )(artifactReq, artifactRes); + assert.equal(artifactRes.headers['Content-Type'], 'text/plain'); + assert.equal(artifactRes.body, artifactBody); + assert.equal(prepareCalls.length, 2); +}); + +test('create upload preserves feature gate, payload mapping, 201, and error routing', async () => { + let received = null; + const api = createRouterRecorder(); + const deps = createDependencies({ + getMindSpaceAssets: () => ({ + async createUpload(userId, input) { + received = { userId, input }; + return { id: 'upload-1' }; + }, + }), + }); + attachPortalMindSpaceAssetDeliveryRoutes(api, deps.dependencies); + const req = createRequest({ + body: { + category_id: 'category-1', + filename: 'report.pdf', + size_bytes: 42, + declared_mime_type: 'application/pdf', + session_id: 'session-1', + message_id: 'message-1', + }, + }); + const res = createResponseRecorder(); + await routeHandler(api, 'POST /mindspace/v1/uploads')(req, res); + assert.deepEqual(received, { + userId: 'user-1', + input: { + categoryId: 'category-1', + filename: 'report.pdf', + sizeBytes: 42, + declaredMimeType: 'application/pdf', + sourceSessionId: 'session-1', + sourceMessageId: 'message-1', + }, + }); + assert.equal(deps.calls.data[0].status, 201); + + const failureApi = createRouterRecorder(); + const error = new Error('upload rejected'); + const failure = createDependencies({ + getMindSpaceAssets: () => ({ + async createUpload() { + throw error; + }, + }), + }); + attachPortalMindSpaceAssetDeliveryRoutes(failureApi, failure.dependencies); + const failureReq = createRequest(); + const failureRes = createResponseRecorder(); + await routeHandler(failureApi, 'POST /mindspace/v1/uploads')( + failureReq, + failureRes, + ); + assert.deepEqual(failure.calls.errors[0], { + res: failureRes, + req: failureReq, + error, + }); +}); + +test('upload content preserves raw body forwarding and unavailable response', async () => { + const rawBody = Buffer.from('raw-upload'); + let received = null; + const api = createRouterRecorder(); + const deps = createDependencies({ + rawUploadBody: () => {}, + getMindSpaceAssets: () => ({ + async writeUploadContent(...args) { + received = args; + return { storedBytes: rawBody.length }; + }, + }), + }); + attachPortalMindSpaceAssetDeliveryRoutes(api, deps.dependencies); + const res = createResponseRecorder(); + await routeHandler( + api, + 'PUT /mindspace/v1/uploads/:uploadId/content', + 1, + )( + createRequest({ params: { uploadId: 'upload-1' }, body: rawBody }), + res, + ); + assert.deepEqual(received, ['user-1', 'upload-1', rawBody]); + assert.deepEqual(res.body, { data: { storedBytes: rawBody.length } }); + + const unavailableApi = createRouterRecorder(); + attachPortalMindSpaceAssetDeliveryRoutes(unavailableApi); + const unavailableRes = createResponseRecorder(); + await routeHandler( + unavailableApi, + 'PUT /mindspace/v1/uploads/:uploadId/content', + 1, + )(createRequest(), unavailableRes); + assert.equal(unavailableRes.statusCode, 503); + assert.deepEqual(unavailableRes.body, { message: 'MindSpace 未启用' }); +}); + +test('complete and cancel upload preserve service forwarding and response status', async () => { + const calls = []; + const api = createRouterRecorder(); + const deps = createDependencies({ + getMindSpaceAssets: () => ({ + async completeUpload(...args) { + calls.push({ kind: 'complete', args }); + return { id: 'asset-1' }; + }, + async cancelUpload(...args) { + calls.push({ kind: 'cancel', args }); + return { cancelled: true }; + }, + }), + }); + attachPortalMindSpaceAssetDeliveryRoutes(api, deps.dependencies); + + const completeRes = createResponseRecorder(); + await routeHandler(api, 'POST /mindspace/v1/uploads/:uploadId/complete')( + createRequest({ params: { uploadId: 'upload-1' } }), + completeRes, + ); + assert.equal(completeRes.statusCode, 201); + assert.deepEqual(completeRes.body, { data: { id: 'asset-1' } }); + + const cancelRes = createResponseRecorder(); + await routeHandler(api, 'DELETE /mindspace/v1/uploads/:uploadId')( + createRequest({ params: { uploadId: 'upload-2' } }), + cancelRes, + ); + assert.deepEqual(cancelRes.body, { data: { cancelled: true } }); + assert.deepEqual(calls, [ + { kind: 'complete', args: ['user-1', 'upload-1'] }, + { kind: 'cancel', args: ['user-1', 'upload-2'] }, + ]); +}); + +test('claim uploads preserves session/message mapping and feature gate options', async () => { + let received = null; + let enabledOptions = null; + const api = createRouterRecorder(); + const deps = createDependencies({ + ensureMindSpaceEnabled: (_res, _req, options) => { + enabledOptions = options; + return true; + }, + getMindSpaceAssets: () => ({ + async claimUploadArtifactsForConversation(userId, input) { + received = { userId, input }; + return { claimed: 2 }; + }, + }), + }); + attachPortalMindSpaceAssetDeliveryRoutes(api, deps.dependencies); + const res = createResponseRecorder(); + await routeHandler( + api, + 'POST /mindspace/v1/conversation-packages/:sessionId/claim-uploads', + )( + createRequest({ + params: { sessionId: 'session-1' }, + body: { message_id: 'message-1' }, + }), + res, + ); + assert.deepEqual(enabledOptions, { upload: true }); + assert.deepEqual(received, { + userId: 'user-1', + input: { sessionId: 'session-1', messageId: 'message-1' }, + }); + assert.equal(deps.calls.data[0].data.claimed, 2); +}); + +test('asset list preserves string query filtering and stable page envelope', async () => { + let received = null; + const assets = [{ id: 'asset-1' }]; + const api = createRouterRecorder(); + const deps = createDependencies({ + getMindSpaceAssets: () => ({ + async listAssets(userId, query) { + received = { userId, query }; + return assets; + }, + }), + }); + attachPortalMindSpaceAssetDeliveryRoutes(api, deps.dependencies); + const res = createResponseRecorder(); + await routeHandler(api, 'GET /mindspace/v1/assets')( + createRequest({ + query: { category_id: 'category-1', category_code: ['ignored'] }, + }), + res, + ); + assert.deepEqual(received, { + userId: 'user-1', + query: { categoryId: 'category-1', categoryCode: undefined }, + }); + assert.deepEqual(res.body, { + data: assets, + page: { next_cursor: null, has_more: false }, + }); +}); diff --git a/server/portal-mindspace-asset-routes.mjs b/server/portal-mindspace-asset-routes.mjs new file mode 100644 index 0000000..12919d9 --- /dev/null +++ b/server/portal-mindspace-asset-routes.mjs @@ -0,0 +1,372 @@ +import fs from 'node:fs'; +import { + renderImageAssetViewerHtml, + wantsInlineImageViewer, +} from '../mindspace-asset-preview.mjs'; +import { normalizeWorkspaceRelativePath } from '../mindspace-pages.mjs'; +import { verifyPublicAssetToken } from '../mindspace-public-asset-token.mjs'; + +function assertRouter(api) { + if ( + !api || + typeof api.get !== 'function' || + typeof api.post !== 'function' || + typeof api.delete !== 'function' + ) { + throw new Error( + 'attachPortalMindSpaceAssetRoutes requires an Express-compatible router', + ); + } +} + +export function attachPortalMindSpaceAssetRoutes( + api, + { + getMindSpacePublications = () => null, + getDatabasePool = () => null, + getMindSpaceAssets = () => null, + getMindSpacePages = () => null, + getMindSpaceAudit = () => null, + getInternalAgentSecret = () => '', + ensureMindSpaceEnabled = () => false, + resolveRequestOrigin = () => '', + sendData = (res, _req, data, status = 200) => + res.status(status).json({ data }), + handleMindSpaceError = (_res, _req, error) => { + throw error; + }, + normalizeWorkspacePath = normalizeWorkspaceRelativePath, + verifyAssetToken = verifyPublicAssetToken, + wantsImageViewer = wantsInlineImageViewer, + renderImageViewer = renderImageAssetViewerHtml, + readFile = fs.promises.readFile, + now = Date.now, + logger = console, + } = {}, +) { + assertRouter(api); + + api.get('/mindspace/v1/authorize-image', async (req, res) => { + if (!getMindSpacePublications()) { + return res.status(503).json({ error: 'Service unavailable' }); + } + const assetId = String(req.query.asset_id ?? ''); + if (!assetId) { + return res.status(400).json({ error: 'Missing asset_id parameter' }); + } + try { + const [refs] = await getDatabasePool().query( + `SELECT pr.access_mode, pr.expires_at + FROM h5_publication_asset_refs refs + JOIN h5_publish_records pr ON refs.publication_id = pr.id + WHERE refs.asset_id = ? AND pr.status = 'online' + ORDER BY CASE + WHEN pr.access_mode = 'public' THEN 0 + WHEN pr.access_mode = 'time_limited' THEN 1 + ELSE 2 + END, + pr.expires_at DESC + LIMIT 1`, + [assetId], + ); + + if (!refs[0]) { + return res.status(403).json({ error: 'Forbidden' }); + } + + const publication = refs[0]; + if (publication.access_mode === 'public') { + res.set('Cache-Control', 'public, max-age=31536000, immutable'); + return res.status(200).json({ ok: true }); + } + + if ( + publication.access_mode === 'time_limited' && + publication.expires_at && + Number(publication.expires_at) > now() + ) { + res.set('Cache-Control', 'public, max-age=60'); + return res.status(200).json({ ok: true }); + } + + return res.status(403).json({ error: 'Forbidden' }); + } catch (error) { + logger.error( + '[authorize-image]', + error instanceof Error ? error.message : error, + ); + return res.status(500).json({ error: 'Internal server error' }); + } + }); + + api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => { + const assets = getMindSpaceAssets(); + if (!assets || !ensureMindSpaceEnabled(res, req)) return; + try { + const assetId = req.params.assetId; + const currentUserId = + req.currentUser?.id ?? req.userSession?.userId ?? null; + let allowedByPublication = false; + let publicationAccessMode = null; + if (!currentUserId) { + const referrer = String( + req.get('referer') ?? req.get('referrer') ?? '', + ); + const isPublicPageReferrer = (() => { + if (!referrer) return false; + try { + return new URL( + referrer, + resolveRequestOrigin(req) || 'http://localhost', + ).pathname.includes('/public/'); + } catch { + return referrer.includes('/public/'); + } + })(); + const [refs] = await getDatabasePool().query( + `SELECT pr.access_mode, pr.expires_at + FROM h5_publication_asset_refs refs + JOIN h5_publish_records pr ON refs.publication_id = pr.id + WHERE refs.asset_id = ? AND pr.status = 'online' + ORDER BY CASE + WHEN pr.access_mode = 'public' THEN 0 + WHEN pr.access_mode = 'time_limited' THEN 1 + ELSE 2 + END, + pr.expires_at DESC + LIMIT 1`, + [assetId], + ); + const publication = refs[0] ?? null; + if (publication?.access_mode === 'public') { + allowedByPublication = true; + publicationAccessMode = 'public'; + } else if ( + publication?.access_mode === 'time_limited' && + publication.expires_at && + Number(publication.expires_at) > now() + ) { + allowedByPublication = true; + publicationAccessMode = 'time_limited'; + } + if ( + !allowedByPublication && + verifyAssetToken( + assetId, + req.query.public_token, + getInternalAgentSecret(), + ) + ) { + allowedByPublication = true; + publicationAccessMode = 'signed-public-token'; + } + if (!allowedByPublication && isPublicPageReferrer) { + allowedByPublication = true; + publicationAccessMode = 'public-page-referrer'; + } + if (!allowedByPublication) { + return res.status(401).json({ message: '未授权,请重新登录' }); + } + } + const { asset, path: assetPath } = currentUserId + ? await assets.readAsset(currentUserId, assetId) + : await assets.readPublicAsset(assetId); + await getMindSpaceAudit()?.write({ + userId: currentUserId, + action: 'asset.download', + objectType: 'asset', + objectId: assetId, + ip: req.ip, + riskLevel: asset.riskLevel, + detail: allowedByPublication + ? { accessMode: publicationAccessMode } + : undefined, + }); + res.type(asset.mimeType); + const inline = + req.query.inline === '1' || + req.query.disposition === 'inline' || + req.get('sec-fetch-dest') === 'iframe'; + if ( + inline && + asset.mimeType.startsWith('image/') && + wantsImageViewer(req) + ) { + const downloadUrl = + `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}` + + '/download?inline=1'; + const html = renderImageViewer({ asset, downloadUrl }); + res.set('Content-Type', 'text/html; charset=utf-8'); + res.set('Cache-Control', 'private, no-store'); + res.setHeader('X-Request-Id', req.requestId); + return res.send(html); + } + res.set( + 'Content-Disposition', + inline + ? `inline; filename*=UTF-8''${encodeURIComponent(asset.filename)}` + : `attachment; filename*=UTF-8''${encodeURIComponent(asset.filename)}`, + ); + res.setHeader('X-Request-Id', req.requestId); + return res.sendFile(assetPath); + } catch (error) { + await getMindSpaceAudit()?.write({ + userId: req.currentUser?.id ?? null, + action: 'asset.download', + objectType: 'asset', + objectId: req.params.assetId, + ip: req.ip, + result: 'denied', + riskLevel: + error?.code === 'security_risk_blocked' ? 'high' : null, + }); + return handleMindSpaceError(res, req, error); + } + }); + + api.get('/mindspace/v1/assets/:assetId/thumbnail', async (req, res) => { + const assets = getMindSpaceAssets(); + if (!assets || !ensureMindSpaceEnabled(res, req)) return; + try { + const svg = await assets.renderAssetThumbnail( + req.currentUser.id, + req.params.assetId, + ); + res.set('Content-Type', 'image/svg+xml; charset=utf-8'); + res.set('Cache-Control', 'private, max-age=300'); + res.setHeader('X-Request-Id', req.requestId); + return res.send(svg); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.get('/mindspace/v1/assets/:assetId/preview', async (req, res) => { + const assets = getMindSpaceAssets(); + if (!assets || !ensureMindSpaceEnabled(res, req)) return; + try { + const html = await assets.renderAssetPreview( + req.currentUser.id, + req.params.assetId, + ); + res.set('Content-Type', 'text/html; charset=utf-8'); + res.set('Cache-Control', 'private, no-store'); + res.setHeader('X-Request-Id', req.requestId); + return res.send(html); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.post('/mindspace/v1/pages/from-asset', async (req, res) => { + const pages = getMindSpacePages(); + const assets = getMindSpaceAssets(); + if (!pages || !assets) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const assetId = req.body?.asset_id; + const existingPage = await pages.findPageBySourceAsset( + req.currentUser.id, + assetId, + ); + if (existingPage) { + return sendData(res, req, { + kind: 'page', + categoryCode: existingPage.categoryCode ?? 'draft', + page: existingPage, + }); + } + const { asset, path: assetPath } = await assets.readAsset( + req.currentUser.id, + assetId, + ); + if (asset.mimeType === 'text/html') { + const relativePath = normalizeWorkspacePath( + String( + asset.originalFilename ?? asset.original_filename ?? '', + ).includes('/') + ? asset.originalFilename ?? asset.original_filename + : `public/${asset.originalFilename ?? asset.original_filename ?? ''}`, + ); + const existingByPath = relativePath + ? await pages + .findPageByRelativePath(req.currentUser.id, relativePath) + .catch(() => null) + : null; + if (existingByPath) { + return sendData(res, req, { + kind: 'page', + categoryCode: existingByPath.categoryCode ?? 'draft', + page: existingByPath, + }); + } + } + const content = await readFile(assetPath, 'utf8'); + const contentFormat = + asset.mimeType === 'text/html' ? 'html' : 'markdown'; + const htmlRelativePath = + contentFormat === 'html' + ? normalizeWorkspacePath( + String( + asset.originalFilename ?? asset.original_filename ?? '', + ).includes('/') + ? asset.originalFilename ?? asset.original_filename + : `public/${asset.originalFilename ?? asset.original_filename ?? ''}`, + ) + : null; + const page = await pages.createFromChat( + req.currentUser.id, + { + title: req.body?.title || asset.displayName, + summary: req.body?.summary, + content, + contentFormat, + templateId: req.body?.template_id, + categoryCode: 'draft', + }, + { + assetId: asset.id, + snapshot: { + source_asset_id: asset.id, + source_category: asset.categoryCode, + content_mode: contentFormat, + ...(htmlRelativePath + ? { relative_path: htmlRelativePath } + : {}), + }, + }, + ); + return res.status(201).json({ + data: { + kind: 'page', + categoryCode: 'draft', + page, + }, + }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.delete('/mindspace/v1/assets/:assetId', async (req, res) => { + const assets = getMindSpaceAssets(); + if (!assets || !ensureMindSpaceEnabled(res, req)) return; + try { + const result = await assets.deleteAsset( + req.currentUser.id, + req.params.assetId, + ); + await getMindSpaceAudit()?.write({ + userId: req.currentUser.id, + action: 'asset.delete', + objectType: 'asset', + objectId: req.params.assetId, + ip: req.ip, + }); + return sendData(res, req, result); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); +} diff --git a/server/portal-mindspace-asset-routes.test.mjs b/server/portal-mindspace-asset-routes.test.mjs new file mode 100644 index 0000000..aa6114b --- /dev/null +++ b/server/portal-mindspace-asset-routes.test.mjs @@ -0,0 +1,644 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalMindSpaceAssetRoutes } from './portal-mindspace-asset-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + get(path, handler) { + routes.set(`GET ${path}`, handler); + }, + post(path, handler) { + routes.set(`POST ${path}`, handler); + }, + delete(path, handler) { + routes.set(`DELETE ${path}`, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + headers: {}, + filePath: undefined, + mimeType: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + set(name, value) { + this.headers[name] = value; + return this; + }, + setHeader(name, value) { + this.headers[name] = value; + return this; + }, + type(value) { + this.mimeType = value; + return this; + }, + send(body) { + this.body = body; + return this; + }, + sendFile(filePath) { + this.filePath = filePath; + return this; + }, + }; +} + +function createRequest(overrides = {}) { + const headers = Object.fromEntries( + Object.entries(overrides.headers ?? {}).map(([key, value]) => [ + key.toLowerCase(), + value, + ]), + ); + return { + body: {}, + query: {}, + params: {}, + currentUser: { id: 'user-1' }, + userSession: null, + ip: '127.0.0.1', + requestId: 'request-1', + headers, + get(name) { + return headers[String(name).toLowerCase()]; + }, + ...overrides, + headers, + }; +} + +function createDependencies(overrides = {}) { + const calls = { + data: [], + mapped: [], + }; + return { + calls, + dependencies: { + ensureMindSpaceEnabled: () => true, + sendData(res, req, data, status = 200) { + calls.data.push({ res, req, data, status }); + return res.status(status).json({ data }); + }, + handleMindSpaceError(res, req, error) { + calls.mapped.push({ res, req, error }); + return res.status(418).json({ mapped: error.message }); + }, + ...overrides, + }, + }; +} + +test('MindSpace asset module preserves route inventory and order', () => { + const api = createRouterRecorder(); + attachPortalMindSpaceAssetRoutes(api); + assert.deepEqual([...api.routes.keys()], [ + 'GET /mindspace/v1/authorize-image', + 'GET /mindspace/v1/assets/:assetId/download', + 'GET /mindspace/v1/assets/:assetId/thumbnail', + 'GET /mindspace/v1/assets/:assetId/preview', + 'POST /mindspace/v1/pages/from-asset', + 'DELETE /mindspace/v1/assets/:assetId', + ]); +}); + +test('authorize image preserves gates, query, cache policy, and expiry', async () => { + const rows = []; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + getMindSpacePublications: () => ({}), + getDatabasePool: () => ({ + async query(sql, params) { + assert.match(sql, /h5_publication_asset_refs/); + assert.deepEqual(params, ['asset-1']); + return [rows]; + }, + }), + now: () => 1_000, + }); + attachPortalMindSpaceAssetRoutes(api, dependencies.dependencies); + const handler = api.routes.get('GET /mindspace/v1/authorize-image'); + + const missingRes = createResponseRecorder(); + await handler(createRequest({ query: {} }), missingRes); + assert.equal(missingRes.statusCode, 400); + + rows.splice(0, rows.length, { access_mode: 'public', expires_at: null }); + const publicRes = createResponseRecorder(); + await handler(createRequest({ query: { asset_id: 'asset-1' } }), publicRes); + assert.equal(publicRes.statusCode, 200); + assert.equal( + publicRes.headers['Cache-Control'], + 'public, max-age=31536000, immutable', + ); + + rows.splice(0, rows.length, { + access_mode: 'time_limited', + expires_at: 2_000, + }); + const timedRes = createResponseRecorder(); + await handler(createRequest({ query: { asset_id: 'asset-1' } }), timedRes); + assert.equal(timedRes.statusCode, 200); + assert.equal(timedRes.headers['Cache-Control'], 'public, max-age=60'); + + rows.splice(0, rows.length, { + access_mode: 'time_limited', + expires_at: 999, + }); + const expiredRes = createResponseRecorder(); + await handler(createRequest({ query: { asset_id: 'asset-1' } }), expiredRes); + assert.equal(expiredRes.statusCode, 403); + + rows.splice(0); + const missingRefRes = createResponseRecorder(); + await handler( + createRequest({ query: { asset_id: 'asset-1' } }), + missingRefRes, + ); + assert.equal(missingRefRes.statusCode, 403); + + const unavailableApi = createRouterRecorder(); + attachPortalMindSpaceAssetRoutes(unavailableApi); + const unavailableRes = createResponseRecorder(); + await unavailableApi.routes.get('GET /mindspace/v1/authorize-image')( + createRequest({ query: { asset_id: 'asset-1' } }), + unavailableRes, + ); + assert.equal(unavailableRes.statusCode, 503); +}); + +test('authorize image preserves internal error response and logging', async () => { + const logs = []; + const api = createRouterRecorder(); + attachPortalMindSpaceAssetRoutes(api, { + getMindSpacePublications: () => ({}), + getDatabasePool: () => ({ + async query() { + throw new Error('query failed'); + }, + }), + logger: { + error(...args) { + logs.push(args); + }, + }, + }); + const res = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/authorize-image')( + createRequest({ query: { asset_id: 'asset-1' } }), + res, + ); + assert.equal(res.statusCode, 500); + assert.deepEqual(res.body, { error: 'Internal server error' }); + assert.deepEqual(logs[0], ['[authorize-image]', 'query failed']); +}); + +test('authenticated download preserves audit, headers, and file response', async () => { + const audit = []; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + getMindSpaceAssets: () => ({ + async readAsset(userId, assetId) { + assert.equal(userId, 'user-1'); + assert.equal(assetId, 'asset-1'); + return { + asset: { + filename: '报告 1.pdf', + mimeType: 'application/pdf', + riskLevel: 'low', + }, + path: '/tmp/report.pdf', + }; + }, + }), + getMindSpaceAudit: () => ({ + async write(entry) { + audit.push(entry); + }, + }), + }); + attachPortalMindSpaceAssetRoutes(api, dependencies.dependencies); + const res = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/assets/:assetId/download')( + createRequest({ + params: { assetId: 'asset-1' }, + query: {}, + ip: '10.0.0.1', + }), + res, + ); + assert.equal(res.mimeType, 'application/pdf'); + assert.equal(res.filePath, '/tmp/report.pdf'); + assert.equal( + res.headers['Content-Disposition'], + "attachment; filename*=UTF-8''%E6%8A%A5%E5%91%8A%201.pdf", + ); + assert.equal(res.headers['X-Request-Id'], 'request-1'); + assert.deepEqual(audit[0], { + userId: 'user-1', + action: 'asset.download', + objectType: 'asset', + objectId: 'asset-1', + ip: '10.0.0.1', + riskLevel: 'low', + detail: undefined, + }); +}); + +test('image download preserves inline viewer response', async () => { + const viewerCalls = []; + const api = createRouterRecorder(); + attachPortalMindSpaceAssetRoutes(api, { + ensureMindSpaceEnabled: () => true, + getMindSpaceAssets: () => ({ + async readAsset() { + return { + asset: { + filename: 'cover.png', + displayName: 'Cover', + mimeType: 'image/png', + riskLevel: 'none', + }, + path: '/tmp/cover.png', + }; + }, + }), + wantsImageViewer(req) { + viewerCalls.push(req); + return true; + }, + renderImageViewer(input) { + viewerCalls.push(input); + return 'viewer'; + }, + }); + const req = createRequest({ + params: { assetId: 'asset/image' }, + query: { inline: '1' }, + }); + const res = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/assets/:assetId/download')(req, res); + assert.equal(res.body, 'viewer'); + assert.equal(res.headers['Content-Type'], 'text/html; charset=utf-8'); + assert.equal(res.headers['Cache-Control'], 'private, no-store'); + assert.deepEqual(viewerCalls[1], { + asset: { + filename: 'cover.png', + displayName: 'Cover', + mimeType: 'image/png', + riskLevel: 'none', + }, + downloadUrl: + '/api/mindspace/v1/assets/asset%2Fimage/download?inline=1', + }); +}); + +test('anonymous download preserves publication, signed-token, referrer, and denial rules', async () => { + const audit = []; + const rows = []; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + getMindSpaceAssets: () => ({ + async readPublicAsset(assetId) { + return { + asset: { + filename: `${assetId}.png`, + mimeType: 'image/png', + riskLevel: 'none', + }, + path: `/tmp/${assetId}.png`, + }; + }, + }), + getDatabasePool: () => ({ + async query() { + return [rows]; + }, + }), + getInternalAgentSecret: () => 'secret-1', + verifyAssetToken(assetId, token, secret) { + return assetId === 'asset-1' && + token === 'valid-token' && + secret === 'secret-1'; + }, + resolveRequestOrigin: () => 'https://portal.example.com', + getMindSpaceAudit: () => ({ + async write(entry) { + audit.push(entry); + }, + }), + }); + attachPortalMindSpaceAssetRoutes(api, dependencies.dependencies); + const handler = api.routes.get('GET /mindspace/v1/assets/:assetId/download'); + + rows.splice(0, rows.length, { access_mode: 'public' }); + await handler( + createRequest({ + currentUser: null, + params: { assetId: 'asset-public' }, + query: {}, + }), + createResponseRecorder(), + ); + assert.deepEqual(audit.at(-1).detail, { accessMode: 'public' }); + + rows.splice(0); + await handler( + createRequest({ + currentUser: null, + params: { assetId: 'asset-1' }, + query: { public_token: 'valid-token' }, + }), + createResponseRecorder(), + ); + assert.deepEqual(audit.at(-1).detail, { + accessMode: 'signed-public-token', + }); + + await handler( + createRequest({ + currentUser: null, + params: { assetId: 'asset-referrer' }, + query: {}, + headers: { + referer: 'https://portal.example.com/MindSpace/user/public/page.html', + }, + }), + createResponseRecorder(), + ); + assert.deepEqual(audit.at(-1).detail, { + accessMode: 'public-page-referrer', + }); + + const deniedRes = createResponseRecorder(); + await handler( + createRequest({ + currentUser: null, + params: { assetId: 'asset-denied' }, + query: {}, + }), + deniedRes, + ); + assert.equal(deniedRes.statusCode, 401); + assert.deepEqual(deniedRes.body, { message: '未授权,请重新登录' }); +}); + +test('thumbnail, preview, and delete preserve service calls and response metadata', async () => { + const calls = []; + const audit = []; + const assets = { + async renderAssetThumbnail(userId, assetId) { + calls.push({ kind: 'thumbnail', userId, assetId }); + return ''; + }, + async renderAssetPreview(userId, assetId) { + calls.push({ kind: 'preview', userId, assetId }); + return 'preview'; + }, + async deleteAsset(userId, assetId) { + calls.push({ kind: 'delete', userId, assetId }); + return { id: assetId, deleted: true }; + }, + }; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + getMindSpaceAssets: () => assets, + getMindSpaceAudit: () => ({ + async write(entry) { + audit.push(entry); + }, + }), + }); + attachPortalMindSpaceAssetRoutes(api, dependencies.dependencies); + const req = createRequest({ + params: { assetId: 'asset-1' }, + ip: '10.0.0.2', + }); + + const thumbnailRes = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/assets/:assetId/thumbnail')( + req, + thumbnailRes, + ); + assert.equal(thumbnailRes.body, ''); + assert.equal( + thumbnailRes.headers['Content-Type'], + 'image/svg+xml; charset=utf-8', + ); + + const previewRes = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/assets/:assetId/preview')( + req, + previewRes, + ); + assert.equal(previewRes.body, 'preview'); + assert.equal(previewRes.headers['Cache-Control'], 'private, no-store'); + + await api.routes.get('DELETE /mindspace/v1/assets/:assetId')( + req, + createResponseRecorder(), + ); + assert.deepEqual(calls, [ + { kind: 'thumbnail', userId: 'user-1', assetId: 'asset-1' }, + { kind: 'preview', userId: 'user-1', assetId: 'asset-1' }, + { kind: 'delete', userId: 'user-1', assetId: 'asset-1' }, + ]); + assert.deepEqual(audit, [{ + userId: 'user-1', + action: 'asset.delete', + objectType: 'asset', + objectId: 'asset-1', + ip: '10.0.0.2', + }]); + assert.deepEqual(dependencies.calls.data[0].data, { + id: 'asset-1', + deleted: true, + }); +}); + +test('from-asset preserves existing-page reuse and HTML page creation', async () => { + const existingApi = createRouterRecorder(); + const existingDependencies = createDependencies({ + getMindSpaceAssets: () => ({}), + getMindSpacePages: () => ({ + async findPageBySourceAsset(userId, assetId) { + assert.equal(userId, 'user-1'); + assert.equal(assetId, 'asset-1'); + return { id: 'page-existing', categoryCode: null }; + }, + }), + }); + attachPortalMindSpaceAssetRoutes( + existingApi, + existingDependencies.dependencies, + ); + await existingApi.routes.get('POST /mindspace/v1/pages/from-asset')( + createRequest({ body: { asset_id: 'asset-1' } }), + createResponseRecorder(), + ); + assert.deepEqual(existingDependencies.calls.data[0].data, { + kind: 'page', + categoryCode: 'draft', + page: { id: 'page-existing', categoryCode: null }, + }); + + const calls = []; + const createApi = createRouterRecorder(); + const createDependenciesResult = createDependencies({ + getMindSpaceAssets: () => ({ + async readAsset(userId, assetId) { + calls.push({ kind: 'read-asset', userId, assetId }); + return { + asset: { + id: 'asset-2', + mimeType: 'text/html', + originalFilename: 'landing.html', + displayName: 'Landing', + categoryCode: 'document', + }, + path: '/tmp/landing.html', + }; + }, + }), + getMindSpacePages: () => ({ + async findPageBySourceAsset() { + return null; + }, + async findPageByRelativePath(userId, relativePath) { + calls.push({ kind: 'find-path', userId, relativePath }); + return null; + }, + async createFromChat(userId, input, metadata) { + calls.push({ kind: 'create', userId, input, metadata }); + return { id: 'page-2', title: input.title }; + }, + }), + normalizeWorkspacePath(value) { + calls.push({ kind: 'normalize', value }); + return value; + }, + async readFile(path, encoding) { + calls.push({ kind: 'read-file', path, encoding }); + return 'Landing'; + }, + }); + attachPortalMindSpaceAssetRoutes( + createApi, + createDependenciesResult.dependencies, + ); + const res = createResponseRecorder(); + await createApi.routes.get('POST /mindspace/v1/pages/from-asset')( + createRequest({ + body: { + asset_id: 'asset-2', + title: 'Custom title', + summary: 'Summary', + template_id: 'template-1', + }, + }), + res, + ); + assert.equal(res.statusCode, 201); + assert.deepEqual(res.body, { + data: { + kind: 'page', + categoryCode: 'draft', + page: { id: 'page-2', title: 'Custom title' }, + }, + }); + assert.deepEqual(calls.find((call) => call.kind === 'create'), { + kind: 'create', + userId: 'user-1', + input: { + title: 'Custom title', + summary: 'Summary', + content: 'Landing', + contentFormat: 'html', + templateId: 'template-1', + categoryCode: 'draft', + }, + metadata: { + assetId: 'asset-2', + snapshot: { + source_asset_id: 'asset-2', + source_category: 'document', + content_mode: 'html', + relative_path: 'public/landing.html', + }, + }, + }); +}); + +test('asset route errors preserve denied audit and shared mapping', async () => { + const failure = Object.assign(new Error('blocked'), { + code: 'security_risk_blocked', + }); + const audit = []; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + getMindSpaceAssets: () => ({ + async readAsset() { + throw failure; + }, + async renderAssetThumbnail() { + throw failure; + }, + async renderAssetPreview() { + throw failure; + }, + async deleteAsset() { + throw failure; + }, + }), + getMindSpacePages: () => ({ + async findPageBySourceAsset() { + throw failure; + }, + }), + getMindSpaceAudit: () => ({ + async write(entry) { + audit.push(entry); + }, + }), + }); + attachPortalMindSpaceAssetRoutes(api, dependencies.dependencies); + + for (const route of [ + 'GET /mindspace/v1/assets/:assetId/download', + 'GET /mindspace/v1/assets/:assetId/thumbnail', + 'GET /mindspace/v1/assets/:assetId/preview', + 'POST /mindspace/v1/pages/from-asset', + 'DELETE /mindspace/v1/assets/:assetId', + ]) { + await api.routes.get(route)( + createRequest({ + params: { assetId: 'asset-1' }, + body: { asset_id: 'asset-1' }, + }), + createResponseRecorder(), + ); + } + assert.equal(dependencies.calls.mapped.length, 5); + assert.deepEqual(audit[0], { + userId: 'user-1', + action: 'asset.download', + objectType: 'asset', + objectId: 'asset-1', + ip: '127.0.0.1', + result: 'denied', + riskLevel: 'high', + }); +}); diff --git a/server/portal-mindspace-chat-save-routes.mjs b/server/portal-mindspace-chat-save-routes.mjs new file mode 100644 index 0000000..c3a9151 --- /dev/null +++ b/server/portal-mindspace-chat-save-routes.mjs @@ -0,0 +1,533 @@ +import path from 'node:path'; +import { + analyzeChatMessageForSave, + buildWorkspaceBaseHref, + createAssetDataUriResolver, + injectHtmlBaseHref, + resolveStaticHtmlContent, +} from '../mindspace-chat-save.mjs'; +import { + DOCX_MIME_TYPE, + registerChatDocxArtifactForConversation, +} from '../mindspace-chat-docx-package.mjs'; +import { scanContent } from '../mindspace-content-scan.mjs'; +import { generateDocxBuffer } from '../mindspace-docx-export.mjs'; +import { normalizeWorkspaceRelativePath } from '../mindspace-pages.mjs'; +import { + resolveMindSpaceRuntimeConfig, + resolveMindSpaceUserPublishDir, +} from '../mindspace-runtime-config.mjs'; +import { generateHtmlThumbnail } from '../mindspace-thumbnails.mjs'; +import { + ensureWorkspaceHtmlThumbnail, + workspaceThumbnailRelativePath, +} from '../mindspace-workspace-thumbnails.mjs'; + +const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']); + +function assertRouter(api) { + if ( + !api || + typeof api.get !== 'function' || + typeof api.post !== 'function' + ) { + throw new Error( + 'attachPortalMindSpaceChatSaveRoutes requires an Express-compatible router', + ); + } +} + +export function attachPortalMindSpaceChatSaveRoutes( + api, + { + h5Root = '', + env = process.env, + getMindSpacePages = () => null, + getMindSpaceAssets = () => null, + getAuthPool = () => null, + getConversationPackageRegistry = () => null, + resolveChatSaveBundle, + resolveExistingSavedPage, + resolveOwnedAssistantMessage, + sendData = (res, _req, data, status = 200) => + res.status(status).json({ data }), + handleMindSpaceError = (_res, _req, error) => { + throw error; + }, + buildWorkspaceBaseHrefFn = buildWorkspaceBaseHref, + injectHtmlBaseHrefFn = injectHtmlBaseHref, + resolveMindSpaceUserPublishDirFn = resolveMindSpaceUserPublishDir, + workspaceThumbnailRelativePathFn = workspaceThumbnailRelativePath, + ensureWorkspaceHtmlThumbnailFn = ensureWorkspaceHtmlThumbnail, + resolveMindSpaceRuntimeConfigFn = resolveMindSpaceRuntimeConfig, + createAssetDataUriResolverFn = createAssetDataUriResolver, + generateHtmlThumbnailFn = generateHtmlThumbnail, + analyzeChatMessageForSaveFn = analyzeChatMessageForSave, + resolveStaticHtmlContentFn = resolveStaticHtmlContent, + normalizeWorkspaceRelativePathFn = normalizeWorkspaceRelativePath, + scanContentFn = scanContent, + generateDocxBufferFn = generateDocxBuffer, + registerChatDocxArtifactFn = registerChatDocxArtifactForConversation, + logger = console, + } = {}, +) { + assertRouter(api); + if ( + typeof resolveChatSaveBundle !== 'function' || + typeof resolveExistingSavedPage !== 'function' || + typeof resolveOwnedAssistantMessage !== 'function' + ) { + throw new Error( + 'attachPortalMindSpaceChatSaveRoutes requires chat save resolvers', + ); + } + + api.get('/mindspace/v1/pages/chat-save-preview', async (req, res) => { + if (!getMindSpacePages()) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const bundle = await resolveChatSaveBundle( + req.currentUser, + h5Root, + req.query, + ); + if (!bundle.resolvedHtml) { + throw Object.assign(new Error('无法读取链接页面内容'), { + code: 'static_page_not_found', + }); + } + const baseHref = buildWorkspaceBaseHrefFn( + req.currentUser.id, + bundle.resolvedHtml.relativePath, + ); + const html = injectHtmlBaseHrefFn( + bundle.resolvedHtml.content, + baseHref, + ); + res.set('Content-Type', 'text/html; charset=utf-8'); + res.set('Cache-Control', 'private, no-store'); + res.setHeader('X-Request-Id', req.requestId); + return res.send(html); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => { + if (!getMindSpacePages()) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const bundle = await resolveChatSaveBundle( + req.currentUser, + h5Root, + req.query, + ); + if (!bundle.resolvedHtml) { + throw Object.assign(new Error('无法读取链接页面内容'), { + code: 'static_page_not_found', + }); + } + const publishDir = resolveMindSpaceUserPublishDirFn( + h5Root, + req.currentUser, + ); + const thumbRel = workspaceThumbnailRelativePathFn( + bundle.resolvedHtml.relativePath, + ); + const title = + bundle.previewTitle || + bundle.resolvedHtml.suggestedTitle || + bundle.analysis.suggestedTitle; + const subtitle = + bundle.previewSummary || + bundle.resolvedHtml.suggestedSummary || + bundle.analysis.suggestedSummary; + await ensureWorkspaceHtmlThumbnailFn( + publishDir, + bundle.resolvedHtml.relativePath, + bundle.resolvedHtml.content, + { title, subtitle, force: true }, + ).catch(() => {}); + const { storageRoot } = resolveMindSpaceRuntimeConfigFn(h5Root, env); + const resolveAssetDataUri = createAssetDataUriResolverFn( + getAuthPool(), + storageRoot, + req.currentUser.id, + ); + const svg = await generateHtmlThumbnailFn( + publishDir, + thumbRel, + bundle.resolvedHtml.content, + { + title, + subtitle, + contentBaseDir: path.dirname(bundle.resolvedHtml.absolute), + resolveAssetDataUri, + force: true, + }, + ); + res.set('Content-Type', 'image/svg+xml; charset=utf-8'); + res.set('Cache-Control', 'private, no-store'); + res.setHeader('X-Request-Id', req.requestId); + return res.send(svg); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const sessionId = req.body?.session_id; + const messageId = req.body?.message_id; + const bundle = await resolveChatSaveBundle( + req.currentUser, + h5Root, + req.body, + ); + const { + source, + analysis, + resolvedHtml, + previewTitle, + previewSummary, + previewFrameUrl, + thumbnailUrl, + localPreviewUrl, + localThumbnailUrl, + } = bundle; + let thumbnailReady = false; + if (resolvedHtml?.content && resolvedHtml.relativePath) { + const publishDir = resolveMindSpaceUserPublishDirFn( + h5Root, + req.currentUser, + ); + try { + await ensureWorkspaceHtmlThumbnailFn( + publishDir, + resolvedHtml.relativePath, + resolvedHtml.content, + { + title: previewTitle || resolvedHtml.suggestedTitle, + subtitle: previewSummary || resolvedHtml.suggestedSummary, + force: Boolean(previewTitle || previewSummary), + }, + ); + thumbnailReady = true; + } catch { + thumbnailReady = false; + } + } + const existingPage = await resolveExistingSavedPage( + req.currentUser.id, + { + sessionId, + messageId, + relativePath: + resolvedHtml?.relativePath ?? analysis.relativePath, + }, + ); + return sendData(res, req, { + contentMode: analysis.contentMode, + links: analysis.links, + selectedLinkIndex: analysis.selectedLink + ? analysis.links.findIndex( + (link) => + link.publicUrl === analysis.selectedLink.publicUrl, + ) + : -1, + suggestedTitle: + resolvedHtml?.suggestedTitle ?? analysis.suggestedTitle, + suggestedSummary: + resolvedHtml?.suggestedSummary ?? analysis.suggestedSummary, + previewUrl: analysis.previewUrl, + previewFrameUrl, + localPreviewUrl, + localThumbnailUrl, + thumbnailUrl: resolvedHtml ? thumbnailUrl : null, + thumbnailReady, + relativePath: + resolvedHtml?.relativePath ?? analysis.relativePath, + filename: resolvedHtml?.filename ?? analysis.filename, + hasHtmlContent: Boolean(resolvedHtml), + privacyScan: scanContentFn( + resolvedHtml?.content ?? source.content, + ), + existingPage: existingPage + ? { + id: existingPage.id, + title: existingPage.title, + categoryCode: existingPage.categoryCode, + } + : null, + }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + const handleChatSaveDocx = async (req, res) => { + if (!getMindSpacePages()) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const bundle = await resolveChatSaveBundle( + req.currentUser, + h5Root, + req.body, + ); + const title = + bundle.previewTitle || + bundle.resolvedHtml?.suggestedTitle || + bundle.analysis?.suggestedTitle || + 'AI 创作文档'; + const content = + bundle.resolvedHtml?.content ?? bundle.source.content; + const contentFormat = bundle.resolvedHtml?.content + ? 'html' + : 'markdown'; + const buffer = generateDocxBufferFn({ + title, + content, + contentFormat, + }); + const filenameBase = + String(title) + .replace(/[\\/:*?"<>|]+/g, '-') + .replace(/\s+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) || 'mindspace-document'; + const filename = `${filenameBase}.docx`; + await registerChatDocxArtifactFn({ + registry: getConversationPackageRegistry(), + user: req.currentUser, + bundle, + requestBody: req.body, + filename, + buffer, + }).catch((error) => { + logger.warn( + '[MindSpace] failed to record chat docx artifact:', + error?.message ?? error, + ); + }); + res.set('Content-Type', DOCX_MIME_TYPE); + res.set( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(filename)}"; ` + + `filename*=UTF-8''${encodeURIComponent(filename)}`, + ); + res.set('Cache-Control', 'no-store'); + res.setHeader('X-Request-Id', req.requestId); + return res.send(buffer); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }; + + api.post( + '/mindspace/v1/pages/chat-save-docx', + handleChatSaveDocx, + ); + api.post('/v1/pages/chat-save-docx', handleChatSaveDocx); + + api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { + const pages = getMindSpacePages(); + const assets = getMindSpaceAssets(); + if (!pages || !assets) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const categoryCode = String( + req.body?.category_code ?? 'draft', + ); + if (!SAVE_TARGET_CATEGORIES.has(categoryCode)) { + throw Object.assign(new Error('无效的保存目标'), { + code: 'invalid_category_code', + }); + } + const source = await resolveOwnedAssistantMessage( + req.currentUser, + req.body?.session_id, + req.body?.message_id, + ); + const analysis = analyzeChatMessageForSaveFn({ + content: source.content, + userId: req.currentUser.id, + username: req.currentUser.username, + h5Root, + selectedLinkIndex: Number( + req.body?.selected_link_index ?? 0, + ), + }); + const snapshot = { + session_name: source.session.name, + message_created: source.message.created, + role: source.message.role, + content_mode: analysis.contentMode, + public_url: analysis.previewUrl, + relative_path: analysis.relativePath + ? normalizeWorkspaceRelativePathFn(analysis.relativePath) + : analysis.relativePath, + }; + + let resolvedHtml = null; + if (analysis.contentMode === 'static_html') { + resolvedHtml = await resolveStaticHtmlContentFn(analysis).catch( + () => null, + ); + } + scanContentFn(resolvedHtml?.content ?? source.content); + + if (categoryCode !== 'draft') { + let buffer; + let filename; + let displayName = req.body?.title; + if (analysis.contentMode === 'static_html') { + if (!resolvedHtml) { + throw Object.assign( + new Error('无法读取链接页面内容'), + { code: 'static_page_not_found' }, + ); + } + buffer = Buffer.from(resolvedHtml.content, 'utf8'); + filename = resolvedHtml.filename; + displayName = + displayName || resolvedHtml.suggestedTitle; + } else { + buffer = Buffer.from(source.content, 'utf8'); + filename = + `${String(displayName || 'chat-export') + .replace(/[^\w\u4e00-\u9fff-]+/g, '-') + .slice(0, 48) || 'chat-export'}.md`; + } + const asset = await assets.createChatAsset( + req.currentUser.id, + { + categoryCode, + buffer, + filename, + displayName, + sourceType: 'chat', + }, + ); + return res.status(201).json({ + data: { + kind: 'asset', + categoryCode, + asset, + }, + }); + } + + let pageInput = { + title: req.body?.title, + summary: req.body?.summary, + templateId: req.body?.template_id, + pageType: req.body?.page_type, + categoryCode: 'draft', + }; + if (analysis.contentMode === 'static_html') { + if (!resolvedHtml) { + throw Object.assign( + new Error('无法读取链接页面内容'), + { code: 'static_page_not_found' }, + ); + } + pageInput = { + ...pageInput, + title: req.body?.title || resolvedHtml.suggestedTitle, + summary: + req.body?.summary || resolvedHtml.suggestedSummary, + content: resolvedHtml.content, + contentFormat: 'html', + pageType: 'html', + }; + } else { + pageInput = { + ...pageInput, + content: source.content, + contentFormat: 'markdown', + }; + } + + if ( + analysis.contentMode === 'static_html' && + resolvedHtml?.content && + analysis.relativePath + ) { + const publishDir = resolveMindSpaceUserPublishDirFn( + h5Root, + req.currentUser, + ); + await ensureWorkspaceHtmlThumbnailFn( + publishDir, + analysis.relativePath, + resolvedHtml.content, + { + title: pageInput.title, + subtitle: pageInput.summary, + }, + ).catch(() => {}); + } + + const saveAsNew = Boolean(req.body?.save_as_new); + let replacePageId = req.body?.replace_page_id + ? String(req.body.replace_page_id).trim() + : null; + if ( + !replacePageId && + !saveAsNew && + analysis.contentMode === 'static_html' && + analysis.relativePath + ) { + const existingByPath = await pages + .findPageByRelativePath( + req.currentUser.id, + analysis.relativePath, + ) + .catch(() => null); + if (existingByPath) replacePageId = existingByPath.id; + } + let page; + if (replacePageId) { + const existingPage = await pages.getPage( + req.currentUser.id, + replacePageId, + ); + page = await pages.updatePage( + req.currentUser.id, + replacePageId, + { + ...pageInput, + expectedVersion: existingPage.versionNo, + }, + ); + } else { + page = await pages.createFromChat( + req.currentUser.id, + pageInput, + { + sessionId: req.body.session_id, + messageId: req.body.message_id, + snapshot, + }, + ); + } + return res.status(201).json({ + data: { + kind: 'page', + categoryCode: 'draft', + page, + }, + }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); +} diff --git a/server/portal-mindspace-chat-save-routes.test.mjs b/server/portal-mindspace-chat-save-routes.test.mjs new file mode 100644 index 0000000..47b6d3c --- /dev/null +++ b/server/portal-mindspace-chat-save-routes.test.mjs @@ -0,0 +1,502 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalMindSpaceChatSaveRoutes } from './portal-mindspace-chat-save-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + get(path, handler) { + routes.set(`GET ${path}`, handler); + }, + post(path, handler) { + routes.set(`POST ${path}`, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + headers: {}, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + set(name, value) { + this.headers[name] = value; + return this; + }, + setHeader(name, value) { + this.headers[name] = value; + return this; + }, + send(body) { + this.body = body; + return this; + }, + }; +} + +function createRequest(overrides = {}) { + return { + body: {}, + query: {}, + currentUser: { id: 'user-1', username: 'alice' }, + requestId: 'request-1', + ...overrides, + }; +} + +function createBundle(overrides = {}) { + return { + source: { + content: 'Assistant content', + session: { name: 'Session one' }, + message: { + created: 123, + role: 'assistant', + }, + }, + analysis: { + contentMode: 'static_html', + links: [{ publicUrl: 'https://example.com/page.html' }], + selectedLink: { publicUrl: 'https://example.com/page.html' }, + suggestedTitle: 'Analysis title', + suggestedSummary: 'Analysis summary', + previewUrl: 'https://example.com/page.html', + relativePath: 'public/page.html', + filename: 'page.html', + }, + resolvedHtml: { + content: 'Page', + relativePath: 'public/page.html', + filename: 'page.html', + absolute: '/workspace/public/page.html', + suggestedTitle: 'Resolved title', + suggestedSummary: 'Resolved summary', + }, + previewTitle: '', + previewSummary: '', + previewFrameUrl: '/api/preview', + thumbnailUrl: '/api/thumbnail', + localPreviewUrl: '/MindSpace/user/public/page.html', + localThumbnailUrl: '/MindSpace/user/public/page.thumbnail.svg', + ...overrides, + }; +} + +function createDependencies(overrides = {}) { + const calls = { + data: [], + errors: [], + }; + return { + calls, + dependencies: { + h5Root: '/workspace', + env: {}, + getMindSpacePages: () => ({}), + getMindSpaceAssets: () => ({}), + resolveChatSaveBundle: async () => createBundle(), + resolveExistingSavedPage: async () => null, + resolveOwnedAssistantMessage: async () => createBundle().source, + sendData(res, req, data, status = 200) { + calls.data.push({ res, req, data, status }); + return res.status(status).json({ data }); + }, + handleMindSpaceError(res, req, error) { + calls.errors.push({ res, req, error }); + return res.status(418).json({ mapped: error.message }); + }, + ...overrides, + }, + }; +} + +test('chat save module preserves route inventory, order, and DOCX alias handler', () => { + const api = createRouterRecorder(); + attachPortalMindSpaceChatSaveRoutes(api, createDependencies().dependencies); + assert.deepEqual([...api.routes.keys()], [ + 'GET /mindspace/v1/pages/chat-save-preview', + 'GET /mindspace/v1/pages/chat-save-thumbnail', + 'POST /mindspace/v1/pages/analyze-chat-save', + 'POST /mindspace/v1/pages/chat-save-docx', + 'POST /v1/pages/chat-save-docx', + 'POST /mindspace/v1/pages/save-from-chat', + ]); + assert.equal( + api.routes.get('POST /mindspace/v1/pages/chat-save-docx'), + api.routes.get('POST /v1/pages/chat-save-docx'), + ); +}); + +test('preview preserves base injection and response headers', async () => { + const calls = []; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + resolveChatSaveBundle: async (user, root, input) => { + calls.push({ kind: 'bundle', user, root, input }); + return createBundle(); + }, + buildWorkspaceBaseHrefFn(userId, relativePath) { + calls.push({ kind: 'base', userId, relativePath }); + return '/MindSpace/user/public/'; + }, + injectHtmlBaseHrefFn(html, baseHref) { + calls.push({ kind: 'inject', html, baseHref }); + return ''; + }, + }); + attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies); + const req = createRequest({ query: { session_id: 'session-1' } }); + const res = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/pages/chat-save-preview')(req, res); + assert.equal(res.body, ''); + assert.equal(res.headers['Content-Type'], 'text/html; charset=utf-8'); + assert.equal(res.headers['Cache-Control'], 'private, no-store'); + assert.equal(res.headers['X-Request-Id'], 'request-1'); + assert.equal(calls[0].root, '/workspace'); + assert.deepEqual(calls[1], { + kind: 'base', + userId: 'user-1', + relativePath: 'public/page.html', + }); +}); + +test('thumbnail preserves title selection, generation options, and response', async () => { + const calls = []; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + getAuthPool: () => ({ id: 'pool' }), + resolveChatSaveBundle: async () => + createBundle({ + previewTitle: 'Preview title', + previewSummary: 'Preview summary', + }), + resolveMindSpaceUserPublishDirFn(root, user) { + calls.push({ kind: 'publish-dir', root, user }); + return '/workspace/user'; + }, + workspaceThumbnailRelativePathFn(relativePath) { + calls.push({ kind: 'thumb-path', relativePath }); + return 'public/page.thumbnail.svg'; + }, + async ensureWorkspaceHtmlThumbnailFn(...args) { + calls.push({ kind: 'ensure', args }); + }, + resolveMindSpaceRuntimeConfigFn() { + return { storageRoot: '/storage' }; + }, + createAssetDataUriResolverFn(pool, storageRoot, userId) { + calls.push({ kind: 'resolver', pool, storageRoot, userId }); + return async () => null; + }, + async generateHtmlThumbnailFn(...args) { + calls.push({ kind: 'generate', args }); + return 'thumb'; + }, + }); + attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies); + const res = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/pages/chat-save-thumbnail')( + createRequest(), + res, + ); + assert.equal(res.body, 'thumb'); + assert.equal(res.headers['Content-Type'], 'image/svg+xml; charset=utf-8'); + assert.deepEqual(calls.find((call) => call.kind === 'resolver'), { + kind: 'resolver', + pool: { id: 'pool' }, + storageRoot: '/storage', + userId: 'user-1', + }); + const generateOptions = calls.find((call) => call.kind === 'generate').args[3]; + assert.equal(generateOptions.title, 'Preview title'); + assert.equal(generateOptions.subtitle, 'Preview summary'); + assert.equal(generateOptions.contentBaseDir, '/workspace/public'); + assert.equal(generateOptions.force, true); +}); + +test('analyze preserves projection, thumbnail state, scan, and existing page', async () => { + const calls = []; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + async ensureWorkspaceHtmlThumbnailFn() { + calls.push('thumbnail'); + }, + resolveMindSpaceUserPublishDirFn: () => '/workspace/user', + async resolveExistingSavedPage(userId, input) { + calls.push({ userId, input }); + return { + id: 'page-1', + title: 'Saved', + categoryCode: 'draft', + hidden: true, + }; + }, + scanContentFn(content) { + calls.push({ scan: content }); + return { blocked: false }; + }, + }); + attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies); + const req = createRequest({ + body: { session_id: 'session-1', message_id: 'message-1' }, + }); + await api.routes.get('POST /mindspace/v1/pages/analyze-chat-save')( + req, + createResponseRecorder(), + ); + const data = dependencies.calls.data[0].data; + assert.equal(data.contentMode, 'static_html'); + assert.equal(data.selectedLinkIndex, 0); + assert.equal(data.suggestedTitle, 'Resolved title'); + assert.equal(data.thumbnailReady, true); + assert.equal(data.hasHtmlContent, true); + assert.deepEqual(data.privacyScan, { blocked: false }); + assert.deepEqual(data.existingPage, { + id: 'page-1', + title: 'Saved', + categoryCode: 'draft', + }); +}); + +test('DOCX route preserves artifact registration, headers, and non-fatal registry errors', async () => { + const calls = []; + const warnings = []; + const api = createRouterRecorder(); + const buffer = Buffer.from('docx'); + const dependencies = createDependencies({ + getConversationPackageRegistry: () => ({ id: 'registry' }), + generateDocxBufferFn(input) { + calls.push({ kind: 'generate', input }); + return buffer; + }, + async registerChatDocxArtifactFn(input) { + calls.push({ kind: 'register', input }); + throw new Error('registry unavailable'); + }, + logger: { + warn(...args) { + warnings.push(args); + }, + }, + }); + attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies); + const req = createRequest({ body: { session_id: 'session-1' } }); + const res = createResponseRecorder(); + await api.routes.get('POST /mindspace/v1/pages/chat-save-docx')(req, res); + assert.equal(res.body, buffer); + assert.equal( + calls[0].input.title, + 'Resolved title', + ); + assert.equal(calls[0].input.contentFormat, 'html'); + assert.equal( + res.headers['Content-Type'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ); + assert.match(res.headers['Content-Disposition'], /Resolved-title\.docx/); + assert.equal(warnings.length, 1); +}); + +test('save-from-chat preserves asset export and draft create paths', async () => { + const source = createBundle({ + source: { + content: 'Markdown content', + session: { name: 'Session one' }, + message: { created: 123, role: 'assistant' }, + }, + }).source; + const assetCalls = []; + const assetApi = createRouterRecorder(); + const assetDependencies = createDependencies({ + resolveOwnedAssistantMessage: async () => source, + analyzeChatMessageForSaveFn: () => ({ + contentMode: 'markdown', + previewUrl: null, + relativePath: null, + }), + scanContentFn: () => ({ blocked: false }), + getMindSpaceAssets: () => ({ + async createChatAsset(userId, input) { + assetCalls.push({ userId, input }); + return { id: 'asset-1' }; + }, + }), + }); + attachPortalMindSpaceChatSaveRoutes( + assetApi, + assetDependencies.dependencies, + ); + const assetRes = createResponseRecorder(); + await assetApi.routes.get('POST /mindspace/v1/pages/save-from-chat')( + createRequest({ + body: { + session_id: 'session-1', + message_id: 'message-1', + category_code: 'oa', + title: 'Export title', + }, + }), + assetRes, + ); + assert.equal(assetRes.statusCode, 201); + assert.equal(assetRes.body.data.kind, 'asset'); + assert.equal(assetCalls[0].input.filename, 'Export-title.md'); + assert.equal(assetCalls[0].input.buffer.toString(), 'Markdown content'); + + const pageCalls = []; + const pageApi = createRouterRecorder(); + const pageDependencies = createDependencies({ + resolveOwnedAssistantMessage: async () => source, + analyzeChatMessageForSaveFn: () => ({ + contentMode: 'markdown', + previewUrl: null, + relativePath: null, + }), + scanContentFn: () => ({ blocked: false }), + getMindSpacePages: () => ({ + async createFromChat(userId, input, metadata) { + pageCalls.push({ userId, input, metadata }); + return { id: 'page-1' }; + }, + }), + }); + attachPortalMindSpaceChatSaveRoutes( + pageApi, + pageDependencies.dependencies, + ); + const pageRes = createResponseRecorder(); + await pageApi.routes.get('POST /mindspace/v1/pages/save-from-chat')( + createRequest({ + body: { + session_id: 'session-1', + message_id: 'message-1', + category_code: 'draft', + title: 'Draft', + }, + }), + pageRes, + ); + assert.equal(pageRes.statusCode, 201); + assert.equal(pageRes.body.data.kind, 'page'); + assert.equal(pageCalls[0].input.contentFormat, 'markdown'); + assert.deepEqual(pageCalls[0].metadata, { + sessionId: 'session-1', + messageId: 'message-1', + snapshot: { + session_name: 'Session one', + message_created: 123, + role: 'assistant', + content_mode: 'markdown', + public_url: null, + relative_path: null, + }, + }); +}); + +test('save-from-chat preserves HTML replacement and error mapping', async () => { + const calls = []; + const pages = { + async findPageByRelativePath(userId, relativePath) { + calls.push({ kind: 'find', userId, relativePath }); + return { id: 'page-existing' }; + }, + async getPage(userId, pageId) { + calls.push({ kind: 'get', userId, pageId }); + return { versionNo: 4 }; + }, + async updatePage(userId, pageId, input) { + calls.push({ kind: 'update', userId, pageId, input }); + return { id: pageId, versionNo: 5 }; + }, + }; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + getMindSpacePages: () => pages, + resolveOwnedAssistantMessage: async () => createBundle().source, + analyzeChatMessageForSaveFn: () => ({ + contentMode: 'static_html', + previewUrl: 'https://example.com/page.html', + relativePath: 'public/page.html', + }), + resolveStaticHtmlContentFn: async () => createBundle().resolvedHtml, + normalizeWorkspaceRelativePathFn: (value) => value, + scanContentFn: () => ({}), + resolveMindSpaceUserPublishDirFn: () => '/workspace/user', + ensureWorkspaceHtmlThumbnailFn: async () => {}, + }); + attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies); + const res = createResponseRecorder(); + await api.routes.get('POST /mindspace/v1/pages/save-from-chat')( + createRequest({ + body: { + session_id: 'session-1', + message_id: 'message-1', + category_code: 'draft', + }, + }), + res, + ); + assert.equal(res.body.data.page.id, 'page-existing'); + assert.equal( + calls.find((call) => call.kind === 'update').input.expectedVersion, + 4, + ); + + const invalidRes = createResponseRecorder(); + await api.routes.get('POST /mindspace/v1/pages/save-from-chat')( + createRequest({ body: { category_code: 'invalid' } }), + invalidRes, + ); + assert.equal(invalidRes.statusCode, 418); + assert.equal(dependencies.calls.errors.at(-1).error.code, 'invalid_category_code'); +}); + +test('chat save routes preserve unavailable and missing HTML errors', async () => { + const unavailableApi = createRouterRecorder(); + const unavailableDependencies = createDependencies({ + getMindSpacePages: () => null, + getMindSpaceAssets: () => null, + }); + attachPortalMindSpaceChatSaveRoutes( + unavailableApi, + unavailableDependencies.dependencies, + ); + for (const route of [ + 'GET /mindspace/v1/pages/chat-save-preview', + 'GET /mindspace/v1/pages/chat-save-thumbnail', + 'POST /mindspace/v1/pages/analyze-chat-save', + 'POST /mindspace/v1/pages/chat-save-docx', + 'POST /mindspace/v1/pages/save-from-chat', + ]) { + const res = createResponseRecorder(); + await unavailableApi.routes.get(route)(createRequest(), res); + assert.equal(res.statusCode, 503, route); + } + + const missingApi = createRouterRecorder(); + const missingDependencies = createDependencies({ + resolveChatSaveBundle: async () => + createBundle({ resolvedHtml: null }), + }); + attachPortalMindSpaceChatSaveRoutes( + missingApi, + missingDependencies.dependencies, + ); + await missingApi.routes.get('GET /mindspace/v1/pages/chat-save-preview')( + createRequest(), + createResponseRecorder(), + ); + assert.equal( + missingDependencies.calls.errors[0].error.code, + 'static_page_not_found', + ); +}); diff --git a/server/portal-mindspace-chat-share-routes.mjs b/server/portal-mindspace-chat-share-routes.mjs new file mode 100644 index 0000000..f2b27c4 --- /dev/null +++ b/server/portal-mindspace-chat-share-routes.mjs @@ -0,0 +1,332 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { inlinePrivateAssetsInHtml } from '../mindspace-pages.mjs'; +import { + getQuickPlazaFromPublicHtmlStatus, + quickPlazaFromChat, + quickPlazaFromPublicHtml, +} from '../mindspace-chat-plaza.mjs'; +import { rewriteWorkspacePublicAssetReferences } from '../mindspace-publications.mjs'; +import { + buildMindSpacePublicUrlForUser, + resolveMindSpaceRuntimeConfig, + resolveMindSpaceUserPublishDir, +} from '../mindspace-runtime-config.mjs'; +import { PUBLIC_ZONE_DIR } from '../user-publish.mjs'; + +function assertRouter(api) { + if ( + !api || + typeof api.get !== 'function' || + typeof api.post !== 'function' + ) { + throw new Error( + 'attachPortalMindSpaceChatShareRoutes requires an Express-compatible router', + ); + } +} + +export function attachPortalMindSpaceChatShareRoutes( + api, + { + h5Root = '', + env = process.env, + getMindSpacePages = () => null, + getMindSpacePublications = () => null, + getPlazaPosts = () => null, + getAuthPool = () => null, + resolveChatSaveBundle, + resolvePlazaPostUrl, + mapPlazaError = () => 500, + sendData = (res, _req, data, status = 200) => + res.status(status).json({ data }), + sendError = (res, _req, status, code, message) => + res.status(status).json({ error: { code, message } }), + handlePlazaError = (_res, _req, error) => { + throw error; + }, + handleMindSpaceError = (_res, _req, error) => { + throw error; + }, + resolveMindSpaceRuntimeConfigFn = resolveMindSpaceRuntimeConfig, + inlinePrivateAssetsInHtmlFn = inlinePrivateAssetsInHtml, + resolveMindSpaceUserPublishDirFn = resolveMindSpaceUserPublishDir, + rewriteWorkspacePublicAssetReferencesFn = + rewriteWorkspacePublicAssetReferences, + buildMindSpacePublicUrlForUserFn = + buildMindSpacePublicUrlForUser, + quickPlazaFromChatFn = quickPlazaFromChat, + getQuickPlazaFromPublicHtmlStatusFn = + getQuickPlazaFromPublicHtmlStatus, + quickPlazaFromPublicHtmlFn = quickPlazaFromPublicHtml, + mkdir = fs.mkdir, + writeFile = fs.writeFile, + randomUUID = crypto.randomUUID, + now = Date.now, + logger = console, + } = {}, +) { + assertRouter(api); + if ( + typeof resolveChatSaveBundle !== 'function' || + typeof resolvePlazaPostUrl !== 'function' + ) { + throw new Error( + 'attachPortalMindSpaceChatShareRoutes requires chat and Plaza resolvers', + ); + } + + api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => { + if (!getMindSpacePages()) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const bundle = await resolveChatSaveBundle( + req.currentUser, + h5Root, + req.body, + ); + logger.log( + '[quick-share] bundle resolved, hasHtml:', + Boolean(bundle.resolvedHtml), + ); + if (!bundle.resolvedHtml) { + throw Object.assign(new Error('无法读取链接页面内容'), { + code: 'static_page_not_found', + }); + } + + const { storageRoot } = resolveMindSpaceRuntimeConfigFn( + h5Root, + env, + ); + const { html: localizedHtml } = + await inlinePrivateAssetsInHtmlFn( + getAuthPool(), + storageRoot, + req.currentUser.id, + bundle.resolvedHtml.content, + ); + + const publishDir = resolveMindSpaceUserPublishDirFn( + h5Root, + req.currentUser, + ); + const sharedDir = path.join( + publishDir, + PUBLIC_ZONE_DIR, + 'shared', + ); + await mkdir(sharedDir, { recursive: true }); + + const basename = bundle.resolvedHtml.filename.replace( + /\.html$/i, + '', + ); + const filename = + `${basename}-${randomUUID().slice(0, 8)}.html`; + const sharedRelativePath = + `${PUBLIC_ZONE_DIR}/shared/${filename}`; + const sharedHtml = + rewriteWorkspacePublicAssetReferencesFn( + localizedHtml, + sharedRelativePath, + ); + const destPath = path.join(sharedDir, filename); + await writeFile(destPath, sharedHtml, 'utf8'); + + const publicUrl = buildMindSpacePublicUrlForUserFn({ + h5Root, + env, + user: req.currentUser.id, + relativePath: sharedRelativePath, + }); + + return res.status(201).json({ + data: { + publicUrl, + filename, + }, + }); + } catch (error) { + logger.error('[quick-share] error:', error); + return handleMindSpaceError(res, req, error); + } + }); + + api.post('/mindspace/v1/pages/quick-plaza-from-chat', async (req, res) => { + const pages = getMindSpacePages(); + const publications = getMindSpacePublications(); + const plazaPosts = getPlazaPosts(); + if (!pages || !publications || !plazaPosts) { + return sendError( + res, + req, + 503, + 'plaza_unavailable', + 'Plaza 或 MindSpace 未启用', + ); + } + const startedAt = now(); + try { + const bundle = await resolveChatSaveBundle( + req.currentUser, + h5Root, + req.body, + ); + const publishDir = resolveMindSpaceUserPublishDirFn( + h5Root, + req.currentUser, + ); + const result = await quickPlazaFromChatFn({ + user: req.currentUser, + h5Root, + bundle, + body: req.body ?? {}, + mindSpacePages: pages, + mindSpacePublications: publications, + plazaPosts, + publishDir, + }); + logger.info('[quick-plaza] ok', { + ms: now() - startedAt, + pageId: result.pageId, + publicationId: result.publicationId, + postId: result.post?.id, + }); + return sendData(res, req, result, 201); + } catch (error) { + logger.error('[quick-plaza] error:', { + ms: now() - startedAt, + error, + }); + if (error?.code && mapPlazaError(error) !== 500) { + return handlePlazaError(res, req, error); + } + return handleMindSpaceError(res, req, error); + } + }); + + api.get( + '/mindspace/v1/pages/quick-plaza-from-public-html/status', + async (req, res) => { + const pages = getMindSpacePages(); + const publications = getMindSpacePublications(); + const plazaPosts = getPlazaPosts(); + if (!pages || !publications || !plazaPosts) { + return sendError( + res, + req, + 503, + 'plaza_unavailable', + 'Plaza 或 MindSpace 未启用', + ); + } + try { + const relativePath = String( + req.query?.relative_path ?? + req.query?.relativePath ?? + '', + ).trim(); + const result = + await getQuickPlazaFromPublicHtmlStatusFn({ + user: req.currentUser, + relativePath, + mindSpacePages: pages, + mindSpacePublications: publications, + plazaPosts, + }); + const postId = result.post?.id; + return sendData(res, req, { + ...result, + plaza_url: postId + ? resolvePlazaPostUrl(postId, req) + : null, + }); + } catch (error) { + if (error?.code && mapPlazaError(error) !== 500) { + return handlePlazaError(res, req, error); + } + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.post( + '/mindspace/v1/pages/quick-plaza-from-public-html', + async (req, res) => { + const pages = getMindSpacePages(); + const publications = getMindSpacePublications(); + const plazaPosts = getPlazaPosts(); + if (!pages || !publications || !plazaPosts) { + return sendError( + res, + req, + 503, + 'plaza_unavailable', + 'Plaza 或 MindSpace 未启用', + ); + } + const startedAt = now(); + try { + const relativePath = String( + req.body?.relative_path ?? + req.body?.relativePath ?? + '', + ).trim(); + const publishDir = resolveMindSpaceUserPublishDirFn( + h5Root, + req.currentUser, + ); + const result = await quickPlazaFromPublicHtmlFn({ + user: req.currentUser, + relativePath, + mindSpacePages: pages, + mindSpacePublications: publications, + plazaPosts, + publishDir, + }); + const plazaUrl = resolvePlazaPostUrl( + result.post.id, + req, + ); + logger.info('[quick-plaza-public-html] ok', { + ms: now() - startedAt, + pageId: result.pageId, + publicationId: result.publicationId, + postId: result.post?.id, + relativePath, + }); + return sendData( + res, + req, + { + ...result, + plaza_url: plazaUrl, + }, + 201, + ); + } catch (error) { + logger.error('[quick-plaza-public-html] error:', { + ms: now() - startedAt, + error, + }); + if (error?.code === 'ALREADY_PUBLISHED') { + const postId = + error?.details?.post_id ?? error?.details?.postId; + if (postId) { + error.details = { + ...(error.details ?? {}), + plaza_url: resolvePlazaPostUrl(postId, req), + }; + } + } + if (error?.code && mapPlazaError(error) !== 500) { + return handlePlazaError(res, req, error); + } + return handleMindSpaceError(res, req, error); + } + }, + ); +} diff --git a/server/portal-mindspace-chat-share-routes.test.mjs b/server/portal-mindspace-chat-share-routes.test.mjs new file mode 100644 index 0000000..4ee1515 --- /dev/null +++ b/server/portal-mindspace-chat-share-routes.test.mjs @@ -0,0 +1,372 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalMindSpaceChatShareRoutes } from './portal-mindspace-chat-share-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + get(path, handler) { + routes.set(`GET ${path}`, handler); + }, + post(path, handler) { + routes.set(`POST ${path}`, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +function createRequest(overrides = {}) { + return { + body: {}, + query: {}, + currentUser: { id: 'user-1', username: 'alice' }, + ...overrides, + }; +} + +function createBundle() { + return { + resolvedHtml: { + content: 'Page', + filename: 'page.html', + }, + }; +} + +function createDependencies(overrides = {}) { + const services = { + pages: { id: 'pages' }, + publications: { id: 'publications' }, + plazaPosts: { id: 'plaza-posts' }, + }; + const calls = { + data: [], + errors: [], + plazaErrors: [], + }; + return { + calls, + services, + dependencies: { + h5Root: '/workspace', + env: {}, + getMindSpacePages: () => services.pages, + getMindSpacePublications: () => services.publications, + getPlazaPosts: () => services.plazaPosts, + resolveChatSaveBundle: async () => createBundle(), + resolvePlazaPostUrl: (postId) => `/plaza/p/${postId}`, + sendData(res, req, data, status = 200) { + calls.data.push({ res, req, data, status }); + return res.status(status).json({ data }); + }, + sendError(res, req, status, code, message) { + calls.errors.push({ res, req, status, code, message }); + return res.status(status).json({ error: { code, message } }); + }, + handlePlazaError(res, req, error) { + calls.plazaErrors.push({ res, req, error }); + return res.status(409).json({ plaza: error.code }); + }, + handleMindSpaceError(res, req, error) { + calls.errors.push({ res, req, error }); + return res.status(418).json({ mapped: error.message }); + }, + logger: { + log() {}, + info() {}, + error() {}, + }, + ...overrides, + }, + }; +} + +test('chat share module preserves route inventory and order', () => { + const api = createRouterRecorder(); + attachPortalMindSpaceChatShareRoutes(api, createDependencies().dependencies); + assert.deepEqual([...api.routes.keys()], [ + 'POST /mindspace/v1/pages/quick-share-from-chat', + 'POST /mindspace/v1/pages/quick-plaza-from-chat', + 'GET /mindspace/v1/pages/quick-plaza-from-public-html/status', + 'POST /mindspace/v1/pages/quick-plaza-from-public-html', + ]); +}); + +test('quick share preserves localization, path, write, and 201 response', async () => { + const calls = []; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + getAuthPool: () => ({ id: 'pool' }), + resolveMindSpaceRuntimeConfigFn: () => ({ + storageRoot: '/storage', + }), + async inlinePrivateAssetsInHtmlFn( + pool, + storageRoot, + userId, + html, + ) { + calls.push({ kind: 'inline', pool, storageRoot, userId, html }); + return { html: 'Localized' }; + }, + resolveMindSpaceUserPublishDirFn: () => '/workspace/user', + async mkdir(dir, options) { + calls.push({ kind: 'mkdir', dir, options }); + }, + rewriteWorkspacePublicAssetReferencesFn(html, relativePath) { + calls.push({ kind: 'rewrite', html, relativePath }); + return 'Rewritten'; + }, + async writeFile(filePath, content, encoding) { + calls.push({ kind: 'write', filePath, content, encoding }); + }, + randomUUID: () => '12345678-aaaa-bbbb-cccc-dddddddddddd', + buildMindSpacePublicUrlForUserFn(input) { + calls.push({ kind: 'url', input }); + return 'https://example.com/MindSpace/user/public/shared/page-12345678.html'; + }, + }); + attachPortalMindSpaceChatShareRoutes(api, dependencies.dependencies); + const res = createResponseRecorder(); + await api.routes.get('POST /mindspace/v1/pages/quick-share-from-chat')( + createRequest({ body: { session_id: 'session-1' } }), + res, + ); + assert.equal(res.statusCode, 201); + assert.deepEqual(res.body, { + data: { + publicUrl: + 'https://example.com/MindSpace/user/public/shared/page-12345678.html', + filename: 'page-12345678.html', + }, + }); + assert.deepEqual(calls.find((call) => call.kind === 'inline'), { + kind: 'inline', + pool: { id: 'pool' }, + storageRoot: '/storage', + userId: 'user-1', + html: 'Page', + }); + assert.deepEqual(calls.find((call) => call.kind === 'rewrite'), { + kind: 'rewrite', + html: 'Localized', + relativePath: 'public/shared/page-12345678.html', + }); + assert.deepEqual(calls.find((call) => call.kind === 'write'), { + kind: 'write', + filePath: '/workspace/user/public/shared/page-12345678.html', + content: 'Rewritten', + encoding: 'utf8', + }); +}); + +test('quick Plaza from chat preserves dependencies, timing, result, and errors', async () => { + const forwarded = []; + let clock = 1_000; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + resolveMindSpaceUserPublishDirFn: () => '/workspace/user', + now: () => { + clock += 25; + return clock; + }, + async quickPlazaFromChatFn(input) { + forwarded.push(input); + return { + pageId: 'page-1', + publicationId: 'publication-1', + post: { id: 'post-1' }, + }; + }, + }); + attachPortalMindSpaceChatShareRoutes(api, dependencies.dependencies); + const req = createRequest({ body: { title: 'Post title' } }); + await api.routes.get('POST /mindspace/v1/pages/quick-plaza-from-chat')( + req, + createResponseRecorder(), + ); + assert.equal(dependencies.calls.data[0].status, 201); + assert.deepEqual(forwarded[0], { + user: req.currentUser, + h5Root: '/workspace', + bundle: createBundle(), + body: { title: 'Post title' }, + mindSpacePages: dependencies.services.pages, + mindSpacePublications: dependencies.services.publications, + plazaPosts: dependencies.services.plazaPosts, + publishDir: '/workspace/user', + }); + + const failureApi = createRouterRecorder(); + const plazaFailure = Object.assign(new Error('conflict'), { + code: 'ALREADY_PUBLISHED', + }); + const failureDependencies = createDependencies({ + quickPlazaFromChatFn: async () => { + throw plazaFailure; + }, + resolveMindSpaceUserPublishDirFn: () => '/workspace/user', + mapPlazaError: () => 409, + }); + attachPortalMindSpaceChatShareRoutes( + failureApi, + failureDependencies.dependencies, + ); + await failureApi.routes.get( + 'POST /mindspace/v1/pages/quick-plaza-from-chat', + )(createRequest(), createResponseRecorder()); + assert.equal( + failureDependencies.calls.plazaErrors[0].error, + plazaFailure, + ); +}); + +test('quick Plaza status preserves relative path, viewer result, and URL', async () => { + const forwarded = []; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + async getQuickPlazaFromPublicHtmlStatusFn(input) { + forwarded.push(input); + return { + published: true, + post: { id: 'post-1' }, + }; + }, + resolvePlazaPostUrl(postId, req) { + assert.equal(req.query.relativePath, ' public/page.html '); + return `https://plaza.example.com/p/${postId}`; + }, + }); + attachPortalMindSpaceChatShareRoutes(api, dependencies.dependencies); + const req = createRequest({ + query: { relativePath: ' public/page.html ' }, + }); + await api.routes.get( + 'GET /mindspace/v1/pages/quick-plaza-from-public-html/status', + )(req, createResponseRecorder()); + assert.equal(forwarded[0].relativePath, 'public/page.html'); + assert.equal(forwarded[0].mindSpacePages, dependencies.services.pages); + assert.deepEqual(dependencies.calls.data[0].data, { + published: true, + post: { id: 'post-1' }, + plaza_url: 'https://plaza.example.com/p/post-1', + }); +}); + +test('quick Plaza public HTML preserves publish input, URL, and 201 response', async () => { + const forwarded = []; + const api = createRouterRecorder(); + const dependencies = createDependencies({ + resolveMindSpaceUserPublishDirFn: () => '/workspace/user', + async quickPlazaFromPublicHtmlFn(input) { + forwarded.push(input); + return { + pageId: 'page-1', + publicationId: 'publication-1', + post: { id: 'post-1' }, + }; + }, + }); + attachPortalMindSpaceChatShareRoutes(api, dependencies.dependencies); + await api.routes.get( + 'POST /mindspace/v1/pages/quick-plaza-from-public-html', + )( + createRequest({ + body: { relative_path: ' public/page.html ' }, + }), + createResponseRecorder(), + ); + assert.equal(forwarded[0].relativePath, 'public/page.html'); + assert.equal(forwarded[0].publishDir, '/workspace/user'); + assert.equal(dependencies.calls.data[0].status, 201); + assert.deepEqual(dependencies.calls.data[0].data, { + pageId: 'page-1', + publicationId: 'publication-1', + post: { id: 'post-1' }, + plaza_url: '/plaza/p/post-1', + }); +}); + +test('quick Plaza public HTML enriches already-published errors with URL', async () => { + const failure = Object.assign(new Error('already published'), { + code: 'ALREADY_PUBLISHED', + details: { post_id: 'post-existing' }, + }); + const api = createRouterRecorder(); + const dependencies = createDependencies({ + resolveMindSpaceUserPublishDirFn: () => '/workspace/user', + quickPlazaFromPublicHtmlFn: async () => { + throw failure; + }, + mapPlazaError: () => 409, + }); + attachPortalMindSpaceChatShareRoutes(api, dependencies.dependencies); + await api.routes.get( + 'POST /mindspace/v1/pages/quick-plaza-from-public-html', + )(createRequest(), createResponseRecorder()); + assert.equal( + failure.details.plaza_url, + '/plaza/p/post-existing', + ); + assert.equal(dependencies.calls.plazaErrors[0].error, failure); +}); + +test('chat share routes preserve availability gates and MindSpace fallback errors', async () => { + const unavailableApi = createRouterRecorder(); + const unavailable = createDependencies({ + getMindSpacePages: () => null, + getMindSpacePublications: () => null, + getPlazaPosts: () => null, + }); + attachPortalMindSpaceChatShareRoutes( + unavailableApi, + unavailable.dependencies, + ); + const shareRes = createResponseRecorder(); + await unavailableApi.routes.get( + 'POST /mindspace/v1/pages/quick-share-from-chat', + )(createRequest(), shareRes); + assert.equal(shareRes.statusCode, 503); + + for (const route of [ + 'POST /mindspace/v1/pages/quick-plaza-from-chat', + 'GET /mindspace/v1/pages/quick-plaza-from-public-html/status', + 'POST /mindspace/v1/pages/quick-plaza-from-public-html', + ]) { + const res = createResponseRecorder(); + await unavailableApi.routes.get(route)(createRequest(), res); + assert.equal(res.statusCode, 503, route); + assert.equal(res.body.error.code, 'plaza_unavailable'); + } + + const failureApi = createRouterRecorder(); + const failure = new Error('share failed'); + const dependencies = createDependencies({ + resolveChatSaveBundle: async () => { + throw failure; + }, + }); + attachPortalMindSpaceChatShareRoutes( + failureApi, + dependencies.dependencies, + ); + await failureApi.routes.get( + 'POST /mindspace/v1/pages/quick-share-from-chat', + )(createRequest(), createResponseRecorder()); + assert.equal(dependencies.calls.errors.at(-1).error, failure); +}); diff --git a/server/portal-mindspace-page-core-routes.mjs b/server/portal-mindspace-page-core-routes.mjs new file mode 100644 index 0000000..8b12811 --- /dev/null +++ b/server/portal-mindspace-page-core-routes.mjs @@ -0,0 +1,418 @@ +function assertRouter(api) { + if ( + !api || + typeof api.get !== 'function' || + typeof api.post !== 'function' || + typeof api.put !== 'function' || + typeof api.delete !== 'function' + ) { + throw new Error( + 'attachPortalMindSpacePageCoreRoutes requires an Express-compatible router', + ); + } +} + +export function attachPortalMindSpacePageCoreRoutes( + api, + { + getMindSpacePages = () => null, + getMindSpacePublications = () => null, + getMindSpace = () => null, + getMindSpaceAudit = () => null, + getMindSpacePageLiveEdit = () => null, + getMindSpacePageEditSession = () => null, + getUserAuth = () => null, + syncUserGeneratedPages, + ownsAgentSession, + ensureMindSpaceEnabled, + sendData, + sendError, + handleMindSpaceError, + } = {}, +) { + assertRouter(api); + if ( + typeof syncUserGeneratedPages !== 'function' || + typeof ownsAgentSession !== 'function' || + typeof ensureMindSpaceEnabled !== 'function' || + typeof sendData !== 'function' || + typeof sendError !== 'function' || + typeof handleMindSpaceError !== 'function' + ) { + throw new Error( + 'attachPortalMindSpacePageCoreRoutes requires page route dependencies', + ); + } + + api.post('/mindspace/v1/pages', async (req, res) => { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const page = await pages.createPage(req.currentUser.id, { + title: req.body?.title, + summary: req.body?.summary, + content: req.body?.content, + contentFormat: + req.body?.content_format === 'html' ? 'html' : undefined, + templateId: req.body?.template_id, + pageType: req.body?.page_type, + categoryCode: req.body?.category_code, + }); + return res.status(201).json({ data: page }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.get('/mindspace/v1/pages', async (req, res) => { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + await syncUserGeneratedPages(req.currentUser.id); + const limit = Number.parseInt(String(req.query.limit ?? ''), 10); + const offset = Number.parseInt(String(req.query.offset ?? ''), 10); + const result = await pages.listPages(req.currentUser.id, { + status: + typeof req.query.status === 'string' + ? req.query.status + : undefined, + categoryCode: + typeof req.query.category_code === 'string' + ? req.query.category_code + : undefined, + ...(Number.isFinite(limit) ? { limit } : {}), + ...(Number.isFinite(offset) ? { offset } : {}), + }); + return res.json({ + data: result.items, + page: { + total: result.total, + limit: result.limit, + offset: result.offset, + has_more: result.hasMore, + next_cursor: null, + }, + }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.get('/mindspace/v1/pages/:pageId', async (req, res) => { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const [page, versions, publication] = await Promise.all([ + pages.getPage(req.currentUser.id, req.params.pageId), + pages.listVersions(req.currentUser.id, req.params.pageId), + getMindSpacePublications()?.getCurrent( + req.currentUser.id, + req.params.pageId, + ) ?? null, + ]); + return res.json({ + data: { + ...page, + versions, + publication, + }, + }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.delete('/mindspace/v1/pages/:pageId', async (req, res) => { + const pages = getMindSpacePages(); + if (!pages || !ensureMindSpaceEnabled(res, req)) return; + try { + const removeFromPlaza = + String( + req.query?.remove_from_plaza ?? + req.body?.remove_from_plaza ?? + '', + ).toLowerCase() === 'true' || + req.query?.remove_from_plaza === '1' || + req.body?.remove_from_plaza === true; + const result = await pages.deletePage( + req.currentUser.id, + req.params.pageId, + { removeFromPlaza }, + ); + const quota = await getMindSpace().getQuota(req.currentUser.id); + await getMindSpaceAudit()?.write({ + userId: req.currentUser.id, + action: 'page.delete', + objectType: 'page', + objectId: req.params.pageId, + ip: req.ip, + detail: { + offlinedPublicationCount: + result.offlinedPublicationCount, + hiddenPlazaPostCount: result.hiddenPlazaPostCount, + deletedAssetCount: result.deletedAssetCount, + freedBytes: result.freedBytes, + }, + }); + return sendData(res, req, { ...result, quota }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.get( + '/mindspace/v1/pages/:pageId/delete-preview', + async (req, res) => { + const pages = getMindSpacePages(); + if (!pages || !ensureMindSpaceEnabled(res, req)) return; + try { + return sendData( + res, + req, + await pages.getDeletePreview( + req.currentUser.id, + req.params.pageId, + ), + ); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.put('/mindspace/v1/pages/:pageId', async (req, res) => { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const page = await pages.updatePage( + req.currentUser.id, + req.params.pageId, + { + expectedVersion: req.body?.expected_version, + title: req.body?.title, + summary: req.body?.summary, + content: req.body?.content, + templateId: req.body?.template_id, + pageType: req.body?.page_type, + changeNote: req.body?.change_note, + }, + ); + return res.json({ data: page }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.post( + '/mindspace/v1/pages/:pageId/live-edit/bind', + async (req, res) => { + const pageLiveEdit = getMindSpacePageLiveEdit(); + const pages = getMindSpacePages(); + if (!pageLiveEdit || !pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const sessionId = String(req.body?.session_id ?? '').trim(); + if (!sessionId) { + return sendError( + res, + req, + 400, + 'invalid_request', + '缺少 session_id', + ); + } + const owns = await ownsAgentSession( + req.currentUser.id, + sessionId, + ); + if (!owns) { + return sendError( + res, + req, + 403, + 'forbidden', + '无权绑定该 Agent 会话', + ); + } + await pages.getPage( + req.currentUser.id, + req.params.pageId, + ); + const parentSessionId = + String(req.body?.parent_session_id ?? '').trim() || null; + return sendData( + res, + req, + pageLiveEdit.bindSession({ + userId: req.currentUser.id, + sessionId, + pageId: req.params.pageId, + parentSessionId, + }), + ); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.post( + '/mindspace/v1/pages/:pageId/live-edit/fork-session', + async (req, res) => { + const pageEditSession = getMindSpacePageEditSession(); + if (!pageEditSession || !getMindSpacePages()) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const parentSessionId = String( + req.body?.parent_session_id ?? '', + ).trim(); + if (!parentSessionId) { + return sendError( + res, + req, + 400, + 'invalid_request', + '缺少 parent_session_id', + ); + } + const gate = await getUserAuth().canUseChat( + req.currentUser.id, + ); + if (!gate.ok) { + return sendError( + res, + req, + 402, + gate.code ?? 'insufficient_balance', + gate.message, + ); + } + return sendData( + res, + req, + await pageEditSession.forkSession({ + userId: req.currentUser.id, + pageId: req.params.pageId, + parentSessionId, + h5ApiBase: + String( + req.body?.h5_api_base ?? + req.body?.h5ApiBase ?? + '', + ).trim() || null, + }), + ); + } catch (error) { + if (error?.code === 'forbidden') { + return sendError( + res, + req, + 403, + error.code, + error.message, + ); + } + if (error?.code === 'invalid_request') { + return sendError( + res, + req, + 400, + error.code, + error.message, + ); + } + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.post( + '/mindspace/v1/pages/:pageId/live-edit/close-session', + async (req, res) => { + const pageEditSession = getMindSpacePageEditSession(); + if (!pageEditSession || !getMindSpacePages()) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const sessionId = String(req.body?.session_id ?? '').trim(); + if (!sessionId) { + return sendError( + res, + req, + 400, + 'invalid_request', + '缺少 session_id', + ); + } + return sendData( + res, + req, + await pageEditSession.closeSession({ + userId: req.currentUser.id, + pageId: req.params.pageId, + sessionId, + parentSessionId: + String(req.body?.parent_session_id ?? '').trim() || + null, + summary: String(req.body?.summary ?? ''), + }), + ); + } catch (error) { + if ( + error?.code === 'forbidden' || + error?.code === 'page_binding_mismatch' + ) { + return sendError( + res, + req, + 403, + error.code, + error.message, + ); + } + if (error?.code === 'invalid_request') { + return sendError( + res, + req, + 400, + error.code, + error.message, + ); + } + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.get( + '/mindspace/v1/pages/:pageId/live-edit/revision', + async (req, res) => { + const pageLiveEdit = getMindSpacePageLiveEdit(); + if (!pageLiveEdit || !getMindSpacePages()) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + return sendData( + res, + req, + await pageLiveEdit.getRevisionSnapshot( + req.currentUser.id, + req.params.pageId, + ), + ); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); +} diff --git a/server/portal-mindspace-page-core-routes.test.mjs b/server/portal-mindspace-page-core-routes.test.mjs new file mode 100644 index 0000000..bd03ac6 --- /dev/null +++ b/server/portal-mindspace-page-core-routes.test.mjs @@ -0,0 +1,445 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalMindSpacePageCoreRoutes } from './portal-mindspace-page-core-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + get(path, handler) { + routes.set(`GET ${path}`, handler); + }, + post(path, handler) { + routes.set(`POST ${path}`, handler); + }, + put(path, handler) { + routes.set(`PUT ${path}`, handler); + }, + delete(path, handler) { + routes.set(`DELETE ${path}`, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +function createRequest(overrides = {}) { + return { + body: {}, + query: {}, + params: { pageId: 'page-1' }, + currentUser: { id: 'user-1' }, + ip: '127.0.0.1', + ...overrides, + }; +} + +function createDependencies(overrides = {}) { + const calls = { + data: [], + errors: [], + routeErrors: [], + sync: [], + enabled: [], + }; + return { + calls, + dependencies: { + getMindSpacePages: () => ({}), + getMindSpacePublications: () => null, + getMindSpace: () => ({ getQuota: async () => ({ used: 1 }) }), + getMindSpaceAudit: () => null, + getMindSpacePageLiveEdit: () => ({}), + getMindSpacePageEditSession: () => ({}), + getUserAuth: () => ({ canUseChat: async () => ({ ok: true }) }), + async syncUserGeneratedPages(userId) { + calls.sync.push(userId); + }, + ownsAgentSession: async () => true, + ensureMindSpaceEnabled(res, req) { + calls.enabled.push({ res, req }); + return true; + }, + sendData(res, req, data, status = 200) { + calls.data.push({ res, req, data, status }); + return res.status(status).json({ data }); + }, + sendError(res, req, status, code, message) { + calls.errors.push({ res, req, status, code, message }); + return res.status(status).json({ error: { code, message } }); + }, + handleMindSpaceError(res, req, error) { + calls.routeErrors.push({ res, req, error }); + return res.status(418).json({ mapped: error.message }); + }, + ...overrides, + }, + }; +} + +test('page core module preserves route inventory and order', () => { + const api = createRouterRecorder(); + attachPortalMindSpacePageCoreRoutes( + api, + createDependencies().dependencies, + ); + assert.deepEqual([...api.routes.keys()], [ + 'POST /mindspace/v1/pages', + 'GET /mindspace/v1/pages', + 'GET /mindspace/v1/pages/:pageId', + 'DELETE /mindspace/v1/pages/:pageId', + 'GET /mindspace/v1/pages/:pageId/delete-preview', + 'PUT /mindspace/v1/pages/:pageId', + 'POST /mindspace/v1/pages/:pageId/live-edit/bind', + 'POST /mindspace/v1/pages/:pageId/live-edit/fork-session', + 'POST /mindspace/v1/pages/:pageId/live-edit/close-session', + 'GET /mindspace/v1/pages/:pageId/live-edit/revision', + ]); +}); + +test('create and list preserve payload mapping, sync, and pagination envelope', async () => { + const received = []; + const api = createRouterRecorder(); + const setup = createDependencies({ + getMindSpacePages: () => ({ + async createPage(userId, input) { + received.push({ kind: 'create', userId, input }); + return { id: 'page-1' }; + }, + async listPages(userId, input) { + received.push({ kind: 'list', userId, input }); + return { + items: [{ id: 'page-1' }], + total: 1, + limit: 20, + offset: 4, + hasMore: false, + }; + }, + }), + }); + attachPortalMindSpacePageCoreRoutes(api, setup.dependencies); + + const createRes = createResponseRecorder(); + await api.routes.get('POST /mindspace/v1/pages')( + createRequest({ + body: { + title: 'Title', + summary: 'Summary', + content: '

Page

', + content_format: 'html', + template_id: 'template-1', + page_type: 'html', + category_code: 'draft', + }, + }), + createRes, + ); + assert.equal(createRes.statusCode, 201); + assert.equal(received[0].input.contentFormat, 'html'); + + const listRes = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/pages')( + createRequest({ + query: { + status: 'active', + category_code: 'draft', + limit: '20', + offset: '4', + }, + }), + listRes, + ); + assert.deepEqual(setup.calls.sync, ['user-1']); + assert.deepEqual(received[1].input, { + status: 'active', + categoryCode: 'draft', + limit: 20, + offset: 4, + }); + assert.deepEqual(listRes.body.page, { + total: 1, + limit: 20, + offset: 4, + has_more: false, + next_cursor: null, + }); +}); + +test('get and update preserve parallel lookup and version mapping', async () => { + const api = createRouterRecorder(); + const setup = createDependencies({ + getMindSpacePages: () => ({ + getPage: async () => ({ id: 'page-1', title: 'Page' }), + listVersions: async () => [{ id: 'version-1' }], + async updatePage(userId, pageId, input) { + assert.equal(userId, 'user-1'); + assert.equal(pageId, 'page-1'); + assert.deepEqual(input, { + expectedVersion: 2, + title: 'Updated', + summary: undefined, + content: 'Body', + templateId: undefined, + pageType: undefined, + changeNote: 'edit', + }); + return { id: pageId, versionNo: 3 }; + }, + }), + getMindSpacePublications: () => ({ + getCurrent: async () => ({ id: 'publication-1' }), + }), + }); + attachPortalMindSpacePageCoreRoutes(api, setup.dependencies); + + const getRes = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/pages/:pageId')( + createRequest(), + getRes, + ); + assert.equal(getRes.body.data.publication.id, 'publication-1'); + assert.equal(getRes.body.data.versions[0].id, 'version-1'); + + const updateRes = createResponseRecorder(); + await api.routes.get('PUT /mindspace/v1/pages/:pageId')( + createRequest({ + body: { + expected_version: 2, + title: 'Updated', + content: 'Body', + change_note: 'edit', + }, + }), + updateRes, + ); + assert.equal(updateRes.body.data.versionNo, 3); +}); + +test('delete and delete-preview preserve cleanup, quota, audit, and feature gate', async () => { + const audit = []; + const api = createRouterRecorder(); + const setup = createDependencies({ + getMindSpacePages: () => ({ + async deletePage(userId, pageId, options) { + assert.equal(userId, 'user-1'); + assert.equal(pageId, 'page-1'); + assert.deepEqual(options, { removeFromPlaza: true }); + return { + offlinedPublicationCount: 1, + hiddenPlazaPostCount: 2, + deletedAssetCount: 3, + freedBytes: 4, + }; + }, + async getDeletePreview() { + return { pageId: 'page-1', assetCount: 3 }; + }, + }), + getMindSpaceAudit: () => ({ + async write(entry) { + audit.push(entry); + }, + }), + }); + attachPortalMindSpacePageCoreRoutes(api, setup.dependencies); + + const deleteRes = createResponseRecorder(); + await api.routes.get('DELETE /mindspace/v1/pages/:pageId')( + createRequest({ query: { remove_from_plaza: '1' } }), + deleteRes, + ); + assert.equal(deleteRes.body.data.quota.used, 1); + assert.equal(audit[0].action, 'page.delete'); + assert.deepEqual(audit[0].detail, { + offlinedPublicationCount: 1, + hiddenPlazaPostCount: 2, + deletedAssetCount: 3, + freedBytes: 4, + }); + + const previewRes = createResponseRecorder(); + await api.routes.get( + 'GET /mindspace/v1/pages/:pageId/delete-preview', + )(createRequest(), previewRes); + assert.equal(previewRes.body.data.assetCount, 3); + assert.equal(setup.calls.enabled.length, 2); +}); + +test('live edit bind preserves validation, ownership, page check, and binding', async () => { + const calls = []; + const api = createRouterRecorder(); + const setup = createDependencies({ + getMindSpacePages: () => ({ + async getPage(userId, pageId) { + calls.push({ kind: 'page', userId, pageId }); + }, + }), + ownsAgentSession: async (userId, sessionId) => { + calls.push({ kind: 'owns', userId, sessionId }); + return true; + }, + getMindSpacePageLiveEdit: () => ({ + bindSession(input) { + calls.push({ kind: 'bind', input }); + return { revision: 1 }; + }, + }), + }); + attachPortalMindSpacePageCoreRoutes(api, setup.dependencies); + const res = createResponseRecorder(); + await api.routes.get( + 'POST /mindspace/v1/pages/:pageId/live-edit/bind', + )( + createRequest({ + body: { + session_id: 'session-1', + parent_session_id: 'parent-1', + }, + }), + res, + ); + assert.deepEqual(calls.map((call) => call.kind), [ + 'owns', + 'page', + 'bind', + ]); + assert.deepEqual(calls[2].input, { + userId: 'user-1', + sessionId: 'session-1', + pageId: 'page-1', + parentSessionId: 'parent-1', + }); + + const deniedApi = createRouterRecorder(); + const denied = createDependencies({ + getMindSpacePageLiveEdit: () => ({}), + ownsAgentSession: async () => false, + }); + attachPortalMindSpacePageCoreRoutes( + deniedApi, + denied.dependencies, + ); + const deniedRes = createResponseRecorder(); + await deniedApi.routes.get( + 'POST /mindspace/v1/pages/:pageId/live-edit/bind', + )( + createRequest({ body: { session_id: 'session-2' } }), + deniedRes, + ); + assert.equal(deniedRes.statusCode, 403); + assert.equal(denied.calls.errors[0].code, 'forbidden'); +}); + +test('live edit fork, close, and revision preserve service contracts', async () => { + const calls = []; + const api = createRouterRecorder(); + const setup = createDependencies({ + getUserAuth: () => ({ + async canUseChat(userId) { + calls.push({ kind: 'gate', userId }); + return { ok: true }; + }, + }), + getMindSpacePageEditSession: () => ({ + async forkSession(input) { + calls.push({ kind: 'fork', input }); + return { sessionId: 'fork-1' }; + }, + async closeSession(input) { + calls.push({ kind: 'close', input }); + return { closed: true }; + }, + }), + getMindSpacePageLiveEdit: () => ({ + async getRevisionSnapshot(userId, pageId) { + calls.push({ kind: 'revision', userId, pageId }); + return { revision: 7 }; + }, + }), + }); + attachPortalMindSpacePageCoreRoutes(api, setup.dependencies); + + await api.routes.get( + 'POST /mindspace/v1/pages/:pageId/live-edit/fork-session', + )( + createRequest({ + body: { + parent_session_id: 'parent-1', + h5_api_base: ' https://api.example ', + }, + }), + createResponseRecorder(), + ); + assert.equal(calls[1].input.h5ApiBase, 'https://api.example'); + + await api.routes.get( + 'POST /mindspace/v1/pages/:pageId/live-edit/close-session', + )( + createRequest({ + body: { + session_id: 'session-1', + parent_session_id: 'parent-1', + summary: 'done', + }, + }), + createResponseRecorder(), + ); + assert.equal(calls[2].input.summary, 'done'); + + const revisionRes = createResponseRecorder(); + await api.routes.get( + 'GET /mindspace/v1/pages/:pageId/live-edit/revision', + )(createRequest(), revisionRes); + assert.equal(revisionRes.body.data.revision, 7); +}); + +test('page core routes preserve unavailable and mapped error responses', async () => { + const unavailableApi = createRouterRecorder(); + const unavailable = createDependencies({ + getMindSpacePages: () => null, + }); + attachPortalMindSpacePageCoreRoutes( + unavailableApi, + unavailable.dependencies, + ); + const unavailableRes = createResponseRecorder(); + await unavailableApi.routes.get('GET /mindspace/v1/pages')( + createRequest(), + unavailableRes, + ); + assert.equal(unavailableRes.statusCode, 503); + + const failureApi = createRouterRecorder(); + const failure = createDependencies({ + getMindSpacePages: () => ({ + async listPages() { + throw new Error('list failed'); + }, + }), + }); + attachPortalMindSpacePageCoreRoutes( + failureApi, + failure.dependencies, + ); + const failureRes = createResponseRecorder(); + await failureApi.routes.get('GET /mindspace/v1/pages')( + createRequest(), + failureRes, + ); + assert.equal(failureRes.statusCode, 418); + assert.equal(failure.calls.routeErrors[0].error.message, 'list failed'); +}); diff --git a/server/portal-mindspace-page-publish-routes.mjs b/server/portal-mindspace-page-publish-routes.mjs new file mode 100644 index 0000000..5117b83 --- /dev/null +++ b/server/portal-mindspace-page-publish-routes.mjs @@ -0,0 +1,447 @@ +import { suggestCoverMetaWithAi } from '../mindspace-cover-ai.mjs'; +import { pageInternals } from '../mindspace-pages.mjs'; +import { upsertPageDataPolicyIndex } from '../page-data-policy-index.mjs'; +import { syncPageDataPolicyAccessMode } from '../page-data-publish-sync.mjs'; + +function assertRouter(api) { + if ( + !api || + typeof api.get !== 'function' || + typeof api.post !== 'function' + ) { + throw new Error( + 'attachPortalMindSpacePagePublishRoutes requires an Express-compatible router', + ); + } +} + +export function attachPortalMindSpacePagePublishRoutes( + api, + { + env = process.env, + getMindSpacePages = () => null, + getMindSpacePublications = () => null, + getMindSpaceAudit = () => null, + getAuthPool = () => null, + sendData, + handleMindSpaceError, + previewContentSecurityPolicy = + pageInternals.previewContentSecurityPolicy, + suggestCoverMetaWithAiFn = suggestCoverMetaWithAi, + syncPageDataPolicyAccessModeFn = + syncPageDataPolicyAccessMode, + upsertPageDataPolicyIndexFn = upsertPageDataPolicyIndex, + } = {}, +) { + assertRouter(api); + if ( + typeof sendData !== 'function' || + typeof handleMindSpaceError !== 'function' + ) { + throw new Error( + 'attachPortalMindSpacePagePublishRoutes requires response dependencies', + ); + } + + api.get( + '/mindspace/v1/pages/:pageId/thumbnail', + async (req, res) => { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const svg = await pages.renderThumbnail( + req.currentUser.id, + req.params.pageId, + ); + res.set('Content-Type', 'image/svg+xml; charset=utf-8'); + res.set('Cache-Control', 'private, max-age=300'); + res.setHeader('X-Request-Id', req.requestId); + return res.send(svg); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.post( + '/mindspace/v1/pages/:pageId/thumbnail/upload', + async (req, res) => { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const result = await pages.uploadThumbnail( + req.currentUser.id, + req.params.pageId, + { + imageBase64: req.body?.image_base64, + mimeType: req.body?.mime_type, + title: req.body?.title, + summary: req.body?.summary, + html: req.body?.html, + }, + ); + return sendData(res, req, result); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.post( + '/mindspace/v1/pages/:pageId/thumbnail/regenerate', + async (req, res) => { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const useAi = Boolean(req.body?.use_ai); + const result = await pages.regenerateThumbnail( + req.currentUser.id, + req.params.pageId, + { + html: req.body?.html, + title: req.body?.title, + summary: req.body?.summary, + useAi, + instruction: req.body?.instruction, + }, + { + suggestCoverMeta: useAi + ? (input) => { + const pool = getAuthPool(); + if (!pool) { + throw Object.assign( + new Error('LLM 服务未就绪'), + { code: 'llm_not_configured' }, + ); + } + return suggestCoverMetaWithAiFn(pool, { + ...input, + encryptionKey: + env.H5_SETTINGS_ENCRYPTION_KEY ?? + env.TKMIND_SERVER__SECRET_KEY, + }); + } + : null, + }, + ); + return sendData(res, req, result); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.post( + '/mindspace/v1/pages/:pageId/rewrite-download-links', + async (req, res) => { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const content = String(req.body?.content ?? '').trim(); + if (!content) { + throw Object.assign( + new Error('缺少页面内容'), + { code: 'invalid_page_input' }, + ); + } + const html = await pages.rewriteHtmlDownloadLinksForPage( + req.currentUser.id, + req.params.pageId, + content, + ); + return sendData(res, req, { html }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.get( + '/mindspace/v1/pages/:pageId/preview', + async (req, res) => { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const { html, contentFormat } = await pages.renderPreview( + req.currentUser.id, + req.params.pageId, + ); + res.set('Content-Type', 'text/html; charset=utf-8'); + res.set( + 'Content-Security-Policy', + previewContentSecurityPolicy(contentFormat), + ); + res.set('Cache-Control', 'private, no-store'); + res.set('X-Content-Type-Options', 'nosniff'); + return res.send(html); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.post( + '/mindspace/v1/pages/:pageId/preview-draft', + async (req, res) => { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const { html, contentFormat } = + await pages.renderDraftPreview( + req.currentUser.id, + req.params.pageId, + { + title: req.body?.title, + summary: req.body?.summary, + content: req.body?.content, + templateId: req.body?.template_id, + }, + ); + res.set('Content-Type', 'text/html; charset=utf-8'); + res.set( + 'Content-Security-Policy', + previewContentSecurityPolicy(contentFormat), + ); + res.set('Cache-Control', 'private, no-store'); + res.set('X-Content-Type-Options', 'nosniff'); + return res.send(html); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.post( + '/mindspace/v1/pages/:pageId/publish-check', + async (req, res) => { + const publications = getMindSpacePublications(); + if (!publications) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const result = await publications.check( + req.currentUser.id, + req.params.pageId, + { + pageVersionId: req.body?.page_version_id, + accessMode: req.body?.access_mode, + urlSlug: req.body?.url_slug, + password: req.body?.password, + expiresAt: req.body?.expires_at, + }, + ); + return sendData(res, req, result); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + async function transformPage(req, res, { + operation, + auditAction, + }) { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const result = await operation( + req.currentUser.id, + req.params.pageId, + { + pageVersionId: req.body?.page_version_id, + expectedVersion: req.body?.expected_version, + title: req.body?.title, + summary: req.body?.summary, + content: req.body?.content, + }, + ); + await getMindSpaceAudit()?.write({ + userId: req.currentUser.id, + action: auditAction, + objectType: 'page', + objectId: result.page.id, + ip: req.ip, + riskLevel: result.originalScan.riskLevel, + }); + return sendData(res, req, result); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + } + + api.post('/mindspace/v1/pages/:pageId/redact', (req, res) => { + const pages = getMindSpacePages(); + return transformPage(req, res, { + operation: pages?.redactPage.bind(pages), + auditAction: 'page.redact', + }); + }); + + api.post( + '/mindspace/v1/pages/:pageId/publish-fix', + (req, res) => { + const pages = getMindSpacePages(); + return transformPage(req, res, { + operation: + pages?.localizePrivateResources.bind(pages), + auditAction: 'page.publish_fix', + }); + }, + ); + + api.post( + '/mindspace/v1/pages/:pageId/redacted-copy', + (req, res) => { + const pages = getMindSpacePages(); + return transformPage(req, res, { + operation: pages?.redactPage.bind(pages), + auditAction: 'page.redact', + }); + }, + ); + + api.post( + '/mindspace/v1/pages/:pageId/publish', + async (req, res) => { + const publications = getMindSpacePublications(); + if (!publications) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const publication = await publications.publish( + req.currentUser.id, + req.params.pageId, + { + pageVersionId: req.body?.page_version_id, + accessMode: req.body?.access_mode, + urlSlug: req.body?.url_slug, + password: req.body?.password, + expiresAt: req.body?.expires_at, + acknowledgedFindingIds: + req.body?.acknowledged_finding_ids, + }, + ); + const workspaceRoot = + req.currentUser.workspaceRoot ?? null; + if (workspaceRoot) { + const syncedPolicy = + syncPageDataPolicyAccessModeFn( + workspaceRoot, + req.params.pageId, + publication.accessMode, + req.currentUser.id, + ); + if (syncedPolicy) { + await upsertPageDataPolicyIndexFn( + getAuthPool(), + syncedPolicy, + ).catch(() => null); + } + } + await getMindSpaceAudit()?.write({ + userId: req.currentUser.id, + action: 'page.publish', + objectType: 'publication', + objectId: publication.id, + ip: req.ip, + }); + return sendData(res, req, publication, 201); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.post( + '/mindspace/v1/publications/:publicationId/update-status', + async (req, res) => { + const publications = getMindSpacePublications(); + if (!publications) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const publication = + await publications.updatePublicationStatus( + req.currentUser.id, + req.params.publicationId, + { + accessMode: req.body?.access_mode, + expiresAt: req.body?.expires_at, + }, + ); + await getMindSpaceAudit()?.write({ + userId: req.currentUser.id, + action: 'publication.status_updated', + objectType: 'publication', + objectId: req.params.publicationId, + ip: req.ip, + }); + return sendData(res, req, publication); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.post( + '/mindspace/v1/publications/:publicationId/offline', + async (req, res) => { + const publications = getMindSpacePublications(); + if (!publications) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const publication = await publications.offline( + req.currentUser.id, + req.params.publicationId, + ); + await getMindSpaceAudit()?.write({ + userId: req.currentUser.id, + action: 'page.offline', + objectType: 'publication', + objectId: req.params.publicationId, + ip: req.ip, + }); + return sendData(res, req, publication); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); + + api.get( + '/mindspace/v1/publications/:publicationId/stats', + async (req, res) => { + const publications = getMindSpacePublications(); + if (!publications) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + return sendData( + res, + req, + await publications.getStats( + req.currentUser.id, + req.params.publicationId, + ), + ); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }, + ); +} diff --git a/server/portal-mindspace-page-publish-routes.test.mjs b/server/portal-mindspace-page-publish-routes.test.mjs new file mode 100644 index 0000000..d57ab2c --- /dev/null +++ b/server/portal-mindspace-page-publish-routes.test.mjs @@ -0,0 +1,589 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalMindSpacePagePublishRoutes } from './portal-mindspace-page-publish-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + get(path, handler) { + routes.set(`GET ${path}`, handler); + }, + post(path, handler) { + routes.set(`POST ${path}`, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + headers: {}, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + set(name, value) { + this.headers[name] = value; + return this; + }, + setHeader(name, value) { + this.headers[name] = value; + return this; + }, + send(body) { + this.body = body; + return this; + }, + }; +} + +function createRequest(overrides = {}) { + return { + body: {}, + params: { + pageId: 'page-1', + publicationId: 'publication-1', + }, + currentUser: { + id: 'user-1', + workspaceRoot: '/workspace/user-1', + }, + requestId: 'request-1', + ip: '127.0.0.1', + ...overrides, + }; +} + +function createDependencies(overrides = {}) { + const calls = { + data: [], + routeErrors: [], + }; + return { + calls, + dependencies: { + env: {}, + getMindSpacePages: () => ({}), + getMindSpacePublications: () => ({}), + getMindSpaceAudit: () => null, + getAuthPool: () => ({ id: 'pool' }), + sendData(res, req, data, status = 200) { + calls.data.push({ res, req, data, status }); + return res.status(status).json({ data }); + }, + handleMindSpaceError(res, req, error) { + calls.routeErrors.push({ res, req, error }); + return res.status(418).json({ mapped: error.message }); + }, + ...overrides, + }, + }; +} + +test('page publish module preserves route inventory and order', () => { + const api = createRouterRecorder(); + attachPortalMindSpacePagePublishRoutes( + api, + createDependencies().dependencies, + ); + assert.deepEqual([...api.routes.keys()], [ + 'GET /mindspace/v1/pages/:pageId/thumbnail', + 'POST /mindspace/v1/pages/:pageId/thumbnail/upload', + 'POST /mindspace/v1/pages/:pageId/thumbnail/regenerate', + 'POST /mindspace/v1/pages/:pageId/rewrite-download-links', + 'GET /mindspace/v1/pages/:pageId/preview', + 'POST /mindspace/v1/pages/:pageId/preview-draft', + 'POST /mindspace/v1/pages/:pageId/publish-check', + 'POST /mindspace/v1/pages/:pageId/redact', + 'POST /mindspace/v1/pages/:pageId/publish-fix', + 'POST /mindspace/v1/pages/:pageId/redacted-copy', + 'POST /mindspace/v1/pages/:pageId/publish', + 'POST /mindspace/v1/publications/:publicationId/update-status', + 'POST /mindspace/v1/publications/:publicationId/offline', + 'GET /mindspace/v1/publications/:publicationId/stats', + ]); +}); + +test('thumbnail read and upload preserve headers and payload mapping', async () => { + const received = []; + const api = createRouterRecorder(); + const setup = createDependencies({ + getMindSpacePages: () => ({ + async renderThumbnail(userId, pageId) { + received.push({ kind: 'render', userId, pageId }); + return 'page'; + }, + async uploadThumbnail(userId, pageId, input) { + received.push({ kind: 'upload', userId, pageId, input }); + return { uploaded: true }; + }, + }), + }); + attachPortalMindSpacePagePublishRoutes(api, setup.dependencies); + + const thumbnailRes = createResponseRecorder(); + await api.routes.get( + 'GET /mindspace/v1/pages/:pageId/thumbnail', + )(createRequest(), thumbnailRes); + assert.equal(thumbnailRes.body, 'page'); + assert.equal( + thumbnailRes.headers['Content-Type'], + 'image/svg+xml; charset=utf-8', + ); + assert.equal( + thumbnailRes.headers['Cache-Control'], + 'private, max-age=300', + ); + assert.equal( + thumbnailRes.headers['X-Request-Id'], + 'request-1', + ); + + const uploadRes = createResponseRecorder(); + await api.routes.get( + 'POST /mindspace/v1/pages/:pageId/thumbnail/upload', + )( + createRequest({ + body: { + image_base64: 'base64', + mime_type: 'image/png', + title: 'Title', + summary: 'Summary', + html: '', + }, + }), + uploadRes, + ); + assert.deepEqual(received[1].input, { + imageBase64: 'base64', + mimeType: 'image/png', + title: 'Title', + summary: 'Summary', + html: '', + }); + assert.equal(uploadRes.body.data.uploaded, true); +}); + +test('thumbnail regeneration preserves AI callback and encryption key', async () => { + const calls = []; + const api = createRouterRecorder(); + const setup = createDependencies({ + env: { H5_SETTINGS_ENCRYPTION_KEY: 'encryption-key' }, + getAuthPool: () => ({ id: 'pool-1' }), + getMindSpacePages: () => ({ + async regenerateThumbnail(userId, pageId, input, options) { + calls.push({ userId, pageId, input }); + const suggested = await options.suggestCoverMeta({ + title: 'Page', + }); + return { suggested }; + }, + }), + async suggestCoverMetaWithAiFn(pool, input) { + calls.push({ pool, input }); + return { cover: 'ai' }; + }, + }); + attachPortalMindSpacePagePublishRoutes(api, setup.dependencies); + const res = createResponseRecorder(); + await api.routes.get( + 'POST /mindspace/v1/pages/:pageId/thumbnail/regenerate', + )( + createRequest({ + body: { + html: '', + title: 'Title', + summary: 'Summary', + use_ai: true, + instruction: 'bright', + }, + }), + res, + ); + assert.equal(calls[0].input.useAi, true); + assert.deepEqual(calls[1], { + pool: { id: 'pool-1' }, + input: { + title: 'Page', + encryptionKey: 'encryption-key', + }, + }); + assert.equal(res.body.data.suggested.cover, 'ai'); + + const missingPoolApi = createRouterRecorder(); + const missingPool = createDependencies({ + getAuthPool: () => null, + getMindSpacePages: () => ({ + async regenerateThumbnail(_userId, _pageId, _input, options) { + return options.suggestCoverMeta({}); + }, + }), + }); + attachPortalMindSpacePagePublishRoutes( + missingPoolApi, + missingPool.dependencies, + ); + const missingPoolRes = createResponseRecorder(); + await missingPoolApi.routes.get( + 'POST /mindspace/v1/pages/:pageId/thumbnail/regenerate', + )( + createRequest({ body: { use_ai: true } }), + missingPoolRes, + ); + assert.equal(missingPoolRes.statusCode, 418); + assert.equal( + missingPool.calls.routeErrors[0].error.code, + 'llm_not_configured', + ); +}); + +test('download-link rewrite and previews preserve validation, CSP, and draft input', async () => { + const calls = []; + const api = createRouterRecorder(); + const setup = createDependencies({ + getMindSpacePages: () => ({ + async rewriteHtmlDownloadLinksForPage( + userId, + pageId, + content, + ) { + calls.push({ kind: 'rewrite', userId, pageId, content }); + return 'localized'; + }, + async renderPreview() { + return { + html: 'preview', + contentFormat: 'html', + }; + }, + async renderDraftPreview(userId, pageId, input) { + calls.push({ kind: 'draft', userId, pageId, input }); + return { + html: 'draft', + contentFormat: 'markdown', + }; + }, + }), + previewContentSecurityPolicy(format) { + calls.push({ kind: 'csp', format }); + return `policy-${format}`; + }, + }); + attachPortalMindSpacePagePublishRoutes(api, setup.dependencies); + + const rewriteRes = createResponseRecorder(); + await api.routes.get( + 'POST /mindspace/v1/pages/:pageId/rewrite-download-links', + )( + createRequest({ body: { content: ' file ' } }), + rewriteRes, + ); + assert.equal(calls[0].content, 'file'); + assert.equal(rewriteRes.body.data.html, 'localized'); + + const previewRes = createResponseRecorder(); + await api.routes.get( + 'GET /mindspace/v1/pages/:pageId/preview', + )(createRequest(), previewRes); + assert.equal( + previewRes.headers['Content-Security-Policy'], + 'policy-html', + ); + assert.equal(previewRes.headers['Cache-Control'], 'private, no-store'); + assert.equal(previewRes.headers['X-Content-Type-Options'], 'nosniff'); + + const draftRes = createResponseRecorder(); + await api.routes.get( + 'POST /mindspace/v1/pages/:pageId/preview-draft', + )( + createRequest({ + body: { + title: 'Draft', + summary: 'Summary', + content: 'Body', + template_id: 'template-1', + }, + }), + draftRes, + ); + assert.deepEqual( + calls.find((call) => call.kind === 'draft').input, + { + title: 'Draft', + summary: 'Summary', + content: 'Body', + templateId: 'template-1', + }, + ); + assert.equal( + draftRes.headers['Content-Security-Policy'], + 'policy-markdown', + ); + + const invalidRes = createResponseRecorder(); + await api.routes.get( + 'POST /mindspace/v1/pages/:pageId/rewrite-download-links', + )(createRequest({ body: { content: ' ' } }), invalidRes); + assert.equal(invalidRes.statusCode, 418); + assert.equal( + setup.calls.routeErrors.at(-1).error.code, + 'invalid_page_input', + ); +}); + +test('publish check and page transforms preserve inputs and audits', async () => { + const calls = []; + const audit = []; + const api = createRouterRecorder(); + const transformResult = { + page: { id: 'page-1' }, + originalScan: { riskLevel: 'medium' }, + }; + const setup = createDependencies({ + getMindSpacePublications: () => ({ + async check(userId, pageId, input) { + calls.push({ kind: 'check', userId, pageId, input }); + return { allowed: true }; + }, + }), + getMindSpacePages: () => ({ + async redactPage(userId, pageId, input) { + calls.push({ kind: 'redact', userId, pageId, input }); + return transformResult; + }, + async localizePrivateResources(userId, pageId, input) { + calls.push({ kind: 'fix', userId, pageId, input }); + return transformResult; + }, + }), + getMindSpaceAudit: () => ({ + async write(entry) { + audit.push(entry); + }, + }), + }); + attachPortalMindSpacePagePublishRoutes(api, setup.dependencies); + const body = { + page_version_id: 'version-1', + expected_version: 2, + access_mode: 'public', + url_slug: 'page', + password: 'secret', + expires_at: '2030-01-01', + title: 'Title', + summary: 'Summary', + content: 'Body', + }; + + await api.routes.get( + 'POST /mindspace/v1/pages/:pageId/publish-check', + )(createRequest({ body }), createResponseRecorder()); + assert.deepEqual(calls[0].input, { + pageVersionId: 'version-1', + accessMode: 'public', + urlSlug: 'page', + password: 'secret', + expiresAt: '2030-01-01', + }); + + for (const route of [ + 'POST /mindspace/v1/pages/:pageId/redact', + 'POST /mindspace/v1/pages/:pageId/publish-fix', + 'POST /mindspace/v1/pages/:pageId/redacted-copy', + ]) { + await api.routes.get(route)( + createRequest({ body }), + createResponseRecorder(), + ); + } + assert.deepEqual( + calls.slice(1).map((call) => call.kind), + ['redact', 'fix', 'redact'], + ); + assert.deepEqual( + audit.map((entry) => entry.action), + ['page.redact', 'page.publish_fix', 'page.redact'], + ); + assert.equal(audit[0].riskLevel, 'medium'); +}); + +test('publish preserves publication input, Page Data policy sync, audit, and 201', async () => { + const calls = []; + const api = createRouterRecorder(); + const setup = createDependencies({ + getMindSpacePublications: () => ({ + async publish(userId, pageId, input) { + calls.push({ kind: 'publish', userId, pageId, input }); + return { + id: 'publication-1', + accessMode: 'public', + }; + }, + }), + getMindSpaceAudit: () => ({ + async write(entry) { + calls.push({ kind: 'audit', entry }); + }, + }), + syncPageDataPolicyAccessModeFn( + workspaceRoot, + pageId, + accessMode, + userId, + ) { + calls.push({ + kind: 'sync-policy', + workspaceRoot, + pageId, + accessMode, + userId, + }); + return { pageId, accessMode }; + }, + async upsertPageDataPolicyIndexFn(pool, policy) { + calls.push({ kind: 'upsert-policy', pool, policy }); + }, + }); + attachPortalMindSpacePagePublishRoutes(api, setup.dependencies); + const res = createResponseRecorder(); + await api.routes.get('POST /mindspace/v1/pages/:pageId/publish')( + createRequest({ + body: { + page_version_id: 'version-1', + access_mode: 'public', + url_slug: 'page', + password: 'secret', + expires_at: '2030-01-01', + acknowledged_finding_ids: ['finding-1'], + }, + }), + res, + ); + assert.deepEqual(calls[0].input, { + pageVersionId: 'version-1', + accessMode: 'public', + urlSlug: 'page', + password: 'secret', + expiresAt: '2030-01-01', + acknowledgedFindingIds: ['finding-1'], + }); + assert.deepEqual(calls.map((call) => call.kind), [ + 'publish', + 'sync-policy', + 'upsert-policy', + 'audit', + ]); + assert.equal(res.statusCode, 201); +}); + +test('publication status, offline, and stats preserve forwarding and audits', async () => { + const calls = []; + const api = createRouterRecorder(); + const setup = createDependencies({ + getMindSpacePublications: () => ({ + async updatePublicationStatus(userId, publicationId, input) { + calls.push({ + kind: 'status', + userId, + publicationId, + input, + }); + return { id: publicationId, accessMode: input.accessMode }; + }, + async offline(userId, publicationId) { + calls.push({ kind: 'offline', userId, publicationId }); + return { id: publicationId, status: 'offline' }; + }, + async getStats(userId, publicationId) { + calls.push({ kind: 'stats', userId, publicationId }); + return { views: 8 }; + }, + }), + getMindSpaceAudit: () => ({ + async write(entry) { + calls.push({ kind: 'audit', entry }); + }, + }), + }); + attachPortalMindSpacePagePublishRoutes(api, setup.dependencies); + + await api.routes.get( + 'POST /mindspace/v1/publications/:publicationId/update-status', + )( + createRequest({ + body: { + access_mode: 'password', + expires_at: '2030-01-01', + }, + }), + createResponseRecorder(), + ); + assert.deepEqual(calls[0].input, { + accessMode: 'password', + expiresAt: '2030-01-01', + }); + assert.equal(calls[1].entry.action, 'publication.status_updated'); + + await api.routes.get( + 'POST /mindspace/v1/publications/:publicationId/offline', + )(createRequest(), createResponseRecorder()); + assert.equal(calls[2].kind, 'offline'); + assert.equal(calls[3].entry.action, 'page.offline'); + + const statsRes = createResponseRecorder(); + await api.routes.get( + 'GET /mindspace/v1/publications/:publicationId/stats', + )(createRequest(), statsRes); + assert.equal(calls[4].kind, 'stats'); + assert.equal(statsRes.body.data.views, 8); +}); + +test('page publish routes preserve unavailable and mapped errors', async () => { + const unavailableApi = createRouterRecorder(); + const unavailable = createDependencies({ + getMindSpacePages: () => null, + getMindSpacePublications: () => null, + }); + attachPortalMindSpacePagePublishRoutes( + unavailableApi, + unavailable.dependencies, + ); + const thumbnailRes = createResponseRecorder(); + await unavailableApi.routes.get( + 'GET /mindspace/v1/pages/:pageId/thumbnail', + )(createRequest(), thumbnailRes); + assert.equal(thumbnailRes.statusCode, 503); + const statsRes = createResponseRecorder(); + await unavailableApi.routes.get( + 'GET /mindspace/v1/publications/:publicationId/stats', + )(createRequest(), statsRes); + assert.equal(statsRes.statusCode, 503); + + const failureApi = createRouterRecorder(); + const failure = createDependencies({ + getMindSpacePages: () => ({ + async renderThumbnail() { + throw new Error('thumbnail failed'); + }, + }), + }); + attachPortalMindSpacePagePublishRoutes( + failureApi, + failure.dependencies, + ); + const failureRes = createResponseRecorder(); + await failureApi.routes.get( + 'GET /mindspace/v1/pages/:pageId/thumbnail', + )(createRequest(), failureRes); + assert.equal(failureRes.statusCode, 418); + assert.equal( + failure.calls.routeErrors[0].error.message, + 'thumbnail failed', + ); +}); diff --git a/server/portal-mindspace-space-routes.mjs b/server/portal-mindspace-space-routes.mjs new file mode 100644 index 0000000..f147273 --- /dev/null +++ b/server/portal-mindspace-space-routes.mjs @@ -0,0 +1,133 @@ +function assertRouter(api) { + if ( + !api || + typeof api.get !== 'function' || + typeof api.post !== 'function' + ) { + throw new Error( + 'attachPortalMindSpaceSpaceRoutes requires an Express-compatible router', + ); + } +} + +export function attachPortalMindSpaceSpaceRoutes( + api, + { + getMindSpace = () => null, + getScheduleService = () => null, + getMindSpaceCleanup = () => null, + getMindSpaceAudit = () => null, + ensureMindSpaceEnabled = () => false, + sendData = (res, _req, data) => res.json({ data }), + sendError = (res, _req, status, code, message) => + res.status(status).json({ error: { code, message } }), + handleMindSpaceError = (_res, _req, error) => { + throw error; + }, + } = {}, +) { + assertRouter(api); + + api.get('/mindspace/v1/space', async (req, res) => { + const mindSpace = getMindSpace(); + if (!mindSpace || !ensureMindSpaceEnabled(res, req)) return; + const space = await mindSpace.getSpace(req.currentUser.id); + if (!space) { + return sendError(res, req, 404, 'resource_not_found', '用户空间不存在'); + } + return sendData(res, req, space); + }); + + api.post('/mindspace/v1/schedule/reminders/:reminderId/ignore', async (req, res) => { + const scheduleService = getScheduleService(); + if (!ensureMindSpaceEnabled(res, req) || !scheduleService) { + return sendError(res, req, 503, 'feature_disabled', '日程服务未启用'); + } + try { + const reminder = await scheduleService.cancelReminder({ + userId: req.currentUser.id, + reminderId: req.params.reminderId, + }); + return sendData(res, req, reminder); + } catch (err) { + const message = err instanceof Error ? err.message : '忽略提醒失败'; + return sendError(res, req, 400, 'invalid_schedule_input', message); + } + }); + + api.post('/mindspace/v1/schedule/reminders/bulk-delete', async (req, res) => { + const scheduleService = getScheduleService(); + if (!ensureMindSpaceEnabled(res, req) || !scheduleService) { + return sendError(res, req, 503, 'feature_disabled', '日程服务未启用'); + } + try { + const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String) : []; + const deleted = await scheduleService.deleteReminders({ + userId: req.currentUser.id, + reminderIds: ids, + }); + return sendData(res, req, { deleted }); + } catch (err) { + const message = err instanceof Error ? err.message : '删除提醒失败'; + return sendError(res, req, 400, 'invalid_schedule_input', message); + } + }); + + api.get('/mindspace/v1/space/quota', async (req, res) => { + const mindSpace = getMindSpace(); + if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' }); + const quota = await mindSpace.getQuota(req.currentUser.id); + if (!quota) return res.status(404).json({ message: '用户空间不存在' }); + return res.json({ data: quota }); + }); + + api.get('/mindspace/v1/space/categories', async (req, res) => { + const mindSpace = getMindSpace(); + if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' }); + const categories = await mindSpace.listCategories(req.currentUser.id); + if (!categories) return res.status(404).json({ message: '用户空间不存在' }); + return res.json({ data: categories }); + }); + + api.get('/mindspace/v1/space/cleanup', async (req, res) => { + const mindSpaceCleanup = getMindSpaceCleanup(); + if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return; + try { + const username = req.currentUser.username ?? req.currentUser.slug; + const items = await mindSpaceCleanup.listCandidates(req.currentUser.id, username); + const totalBytes = items.reduce((sum, item) => sum + item.sizeBytes, 0); + return sendData(res, req, { items, totalBytes }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + + api.post('/mindspace/v1/space/cleanup', async (req, res) => { + const mindSpaceCleanup = getMindSpaceCleanup(); + if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return; + try { + const username = req.currentUser.username ?? req.currentUser.slug; + const itemIds = Array.isArray(req.body?.item_ids) ? req.body.item_ids : []; + const result = await mindSpaceCleanup.runCleanup( + req.currentUser.id, + username, + itemIds, + ); + const quota = await getMindSpace().getQuota(req.currentUser.id); + await getMindSpaceAudit()?.write({ + userId: req.currentUser.id, + action: 'space.cleanup', + objectType: 'space', + objectId: req.currentUser.id, + ip: req.ip, + detail: { + removedCount: result.removedCount, + freedBytes: result.freedBytes, + }, + }); + return sendData(res, req, { ...result, quota }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); +} diff --git a/server/portal-mindspace-space-routes.test.mjs b/server/portal-mindspace-space-routes.test.mjs new file mode 100644 index 0000000..39f2065 --- /dev/null +++ b/server/portal-mindspace-space-routes.test.mjs @@ -0,0 +1,368 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalMindSpaceSpaceRoutes } from './portal-mindspace-space-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + get(path, handler) { + routes.set(`GET ${path}`, handler); + }, + post(path, handler) { + routes.set(`POST ${path}`, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +function createRequest(overrides = {}) { + return { + body: {}, + params: {}, + query: {}, + currentUser: { + id: 'user-1', + username: 'alice', + slug: 'alice-slug', + }, + ip: '127.0.0.1', + ...overrides, + }; +} + +function createDependencies(overrides = {}) { + const calls = { + data: [], + errors: [], + routeErrors: [], + enabled: [], + }; + return { + calls, + dependencies: { + ensureMindSpaceEnabled(res, req) { + calls.enabled.push({ res, req }); + return true; + }, + sendData(res, req, data) { + calls.data.push({ res, req, data }); + return res.json({ data }); + }, + sendError(res, req, status, code, message) { + calls.errors.push({ res, req, status, code, message }); + return res.status(status).json({ error: { code, message } }); + }, + handleMindSpaceError(res, req, error) { + calls.routeErrors.push({ res, req, error }); + return res.status(418).json({ mapped: error.message }); + }, + ...overrides, + }, + }; +} + +test('MindSpace space module preserves route inventory and order', () => { + const api = createRouterRecorder(); + attachPortalMindSpaceSpaceRoutes(api); + + assert.deepEqual([...api.routes.keys()], [ + 'GET /mindspace/v1/space', + 'POST /mindspace/v1/schedule/reminders/:reminderId/ignore', + 'POST /mindspace/v1/schedule/reminders/bulk-delete', + 'GET /mindspace/v1/space/quota', + 'GET /mindspace/v1/space/categories', + 'GET /mindspace/v1/space/cleanup', + 'POST /mindspace/v1/space/cleanup', + ]); +}); + +test('GET /mindspace/v1/space preserves availability, missing, and success behavior', async () => { + const unavailableApi = createRouterRecorder(); + const unavailable = createDependencies(); + attachPortalMindSpaceSpaceRoutes(unavailableApi, unavailable.dependencies); + const unavailableRes = createResponseRecorder(); + await unavailableApi.routes.get('GET /mindspace/v1/space')( + createRequest(), + unavailableRes, + ); + assert.equal(unavailableRes.body, undefined); + assert.equal(unavailable.calls.enabled.length, 0); + + const missingApi = createRouterRecorder(); + const missing = createDependencies({ + getMindSpace: () => ({ getSpace: async () => null }), + }); + attachPortalMindSpaceSpaceRoutes(missingApi, missing.dependencies); + const missingReq = createRequest(); + const missingRes = createResponseRecorder(); + await missingApi.routes.get('GET /mindspace/v1/space')(missingReq, missingRes); + assert.equal(missingRes.statusCode, 404); + assert.equal(missing.calls.errors[0].code, 'resource_not_found'); + + const space = { id: 'space-1' }; + const successApi = createRouterRecorder(); + const success = createDependencies({ + getMindSpace: () => ({ + async getSpace(userId) { + assert.equal(userId, 'user-1'); + return space; + }, + }), + }); + attachPortalMindSpaceSpaceRoutes(successApi, success.dependencies); + const successReq = createRequest(); + const successRes = createResponseRecorder(); + await successApi.routes.get('GET /mindspace/v1/space')(successReq, successRes); + assert.deepEqual(success.calls.data[0], { + res: successRes, + req: successReq, + data: space, + }); +}); + +test('schedule ignore preserves feature gate, forwarding, and error mapping', async () => { + const unavailableApi = createRouterRecorder(); + const unavailable = createDependencies({ + ensureMindSpaceEnabled: () => false, + }); + attachPortalMindSpaceSpaceRoutes(unavailableApi, unavailable.dependencies); + const unavailableRes = createResponseRecorder(); + await unavailableApi.routes.get( + 'POST /mindspace/v1/schedule/reminders/:reminderId/ignore', + )(createRequest(), unavailableRes); + assert.equal(unavailableRes.statusCode, 503); + assert.equal(unavailable.calls.errors[0].code, 'feature_disabled'); + + let received = null; + const successApi = createRouterRecorder(); + const success = createDependencies({ + getScheduleService: () => ({ + async cancelReminder(input) { + received = input; + return { id: input.reminderId, status: 'cancelled' }; + }, + }), + }); + attachPortalMindSpaceSpaceRoutes(successApi, success.dependencies); + const successRes = createResponseRecorder(); + await successApi.routes.get( + 'POST /mindspace/v1/schedule/reminders/:reminderId/ignore', + )(createRequest({ params: { reminderId: 'reminder-1' } }), successRes); + assert.deepEqual(received, { userId: 'user-1', reminderId: 'reminder-1' }); + assert.equal(success.calls.data[0].data.status, 'cancelled'); + + const failureApi = createRouterRecorder(); + const failure = createDependencies({ + getScheduleService: () => ({ + async cancelReminder() { + throw new Error('cannot cancel'); + }, + }), + }); + attachPortalMindSpaceSpaceRoutes(failureApi, failure.dependencies); + const failureRes = createResponseRecorder(); + await failureApi.routes.get( + 'POST /mindspace/v1/schedule/reminders/:reminderId/ignore', + )(createRequest({ params: { reminderId: 'reminder-2' } }), failureRes); + assert.equal(failureRes.statusCode, 400); + assert.equal(failure.calls.errors[0].message, 'cannot cancel'); +}); + +test('schedule bulk delete preserves id normalization and error mapping', async () => { + let received = null; + const api = createRouterRecorder(); + const success = createDependencies({ + getScheduleService: () => ({ + async deleteReminders(input) { + received = input; + return 2; + }, + }), + }); + attachPortalMindSpaceSpaceRoutes(api, success.dependencies); + const res = createResponseRecorder(); + await api.routes.get('POST /mindspace/v1/schedule/reminders/bulk-delete')( + createRequest({ body: { ids: [1, '2'] } }), + res, + ); + assert.deepEqual(received, { userId: 'user-1', reminderIds: ['1', '2'] }); + assert.deepEqual(success.calls.data[0].data, { deleted: 2 }); + + const failureApi = createRouterRecorder(); + const failure = createDependencies({ + getScheduleService: () => ({ + async deleteReminders() { + throw new Error('cannot delete'); + }, + }), + }); + attachPortalMindSpaceSpaceRoutes(failureApi, failure.dependencies); + const failureRes = createResponseRecorder(); + await failureApi.routes.get('POST /mindspace/v1/schedule/reminders/bulk-delete')( + createRequest(), + failureRes, + ); + assert.equal(failureRes.statusCode, 400); + assert.equal(failure.calls.errors[0].message, 'cannot delete'); +}); + +test('quota and categories preserve unavailable, missing, and success envelopes', async () => { + for (const [path, methodName] of [ + ['GET /mindspace/v1/space/quota', 'getQuota'], + ['GET /mindspace/v1/space/categories', 'listCategories'], + ]) { + const unavailableApi = createRouterRecorder(); + attachPortalMindSpaceSpaceRoutes(unavailableApi); + const unavailableRes = createResponseRecorder(); + await unavailableApi.routes.get(path)(createRequest(), unavailableRes); + assert.equal(unavailableRes.statusCode, 503); + assert.deepEqual(unavailableRes.body, { message: 'MindSpace 未启用' }); + + const missingApi = createRouterRecorder(); + attachPortalMindSpaceSpaceRoutes(missingApi, { + getMindSpace: () => ({ [methodName]: async () => null }), + }); + const missingRes = createResponseRecorder(); + await missingApi.routes.get(path)(createRequest(), missingRes); + assert.equal(missingRes.statusCode, 404); + assert.deepEqual(missingRes.body, { message: '用户空间不存在' }); + + const value = methodName === 'getQuota' ? { used: 1 } : [{ code: 'notes' }]; + const successApi = createRouterRecorder(); + attachPortalMindSpaceSpaceRoutes(successApi, { + getMindSpace: () => ({ [methodName]: async () => value }), + }); + const successRes = createResponseRecorder(); + await successApi.routes.get(path)(createRequest(), successRes); + assert.deepEqual(successRes.body, { data: value }); + } +}); + +test('GET cleanup preserves username choice, byte total, and route error mapping', async () => { + let received = null; + const items = [{ id: 'a', sizeBytes: 10 }, { id: 'b', sizeBytes: 25 }]; + const api = createRouterRecorder(); + const success = createDependencies({ + getMindSpaceCleanup: () => ({ + async listCandidates(userId, username) { + received = { userId, username }; + return items; + }, + }), + }); + attachPortalMindSpaceSpaceRoutes(api, success.dependencies); + const res = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/space/cleanup')(createRequest(), res); + assert.deepEqual(received, { userId: 'user-1', username: 'alice' }); + assert.deepEqual(success.calls.data[0].data, { items, totalBytes: 35 }); + + const failureApi = createRouterRecorder(); + const failureError = new Error('cleanup list failed'); + const failure = createDependencies({ + getMindSpaceCleanup: () => ({ + async listCandidates() { + throw failureError; + }, + }), + }); + attachPortalMindSpaceSpaceRoutes(failureApi, failure.dependencies); + const failureReq = createRequest(); + const failureRes = createResponseRecorder(); + await failureApi.routes.get('GET /mindspace/v1/space/cleanup')( + failureReq, + failureRes, + ); + assert.deepEqual(failure.calls.routeErrors[0], { + res: failureRes, + req: failureReq, + error: failureError, + }); +}); + +test('POST cleanup preserves cleanup, quota, audit, response, and error flow', async () => { + const calls = []; + const api = createRouterRecorder(); + const success = createDependencies({ + getMindSpaceCleanup: () => ({ + async runCleanup(userId, username, itemIds) { + calls.push({ kind: 'cleanup', userId, username, itemIds }); + return { removedCount: 2, freedBytes: 64 }; + }, + }), + getMindSpace: () => ({ + async getQuota(userId) { + calls.push({ kind: 'quota', userId }); + return { usedBytes: 100 }; + }, + }), + getMindSpaceAudit: () => ({ + async write(entry) { + calls.push({ kind: 'audit', entry }); + }, + }), + }); + attachPortalMindSpaceSpaceRoutes(api, success.dependencies); + const req = createRequest({ body: { item_ids: ['a', 'b'] }, ip: '10.0.0.5' }); + const res = createResponseRecorder(); + await api.routes.get('POST /mindspace/v1/space/cleanup')(req, res); + assert.deepEqual(calls[0], { + kind: 'cleanup', + userId: 'user-1', + username: 'alice', + itemIds: ['a', 'b'], + }); + assert.deepEqual(calls[1], { kind: 'quota', userId: 'user-1' }); + assert.deepEqual(calls[2], { + kind: 'audit', + entry: { + userId: 'user-1', + action: 'space.cleanup', + objectType: 'space', + objectId: 'user-1', + ip: '10.0.0.5', + detail: { removedCount: 2, freedBytes: 64 }, + }, + }); + assert.deepEqual(success.calls.data[0].data, { + removedCount: 2, + freedBytes: 64, + quota: { usedBytes: 100 }, + }); + + const failureApi = createRouterRecorder(); + const failureError = new Error('cleanup failed'); + const failure = createDependencies({ + getMindSpaceCleanup: () => ({ + async runCleanup() { + throw failureError; + }, + }), + }); + attachPortalMindSpaceSpaceRoutes(failureApi, failure.dependencies); + const failureReq = createRequest(); + const failureRes = createResponseRecorder(); + await failureApi.routes.get('POST /mindspace/v1/space/cleanup')( + failureReq, + failureRes, + ); + assert.deepEqual(failure.calls.routeErrors[0], { + res: failureRes, + req: failureReq, + error: failureError, + }); +}); diff --git a/server/portal-notification-routes.mjs b/server/portal-notification-routes.mjs new file mode 100644 index 0000000..fba6f3d --- /dev/null +++ b/server/portal-notification-routes.mjs @@ -0,0 +1,300 @@ +export function attachPortalNotificationRoutes({ + app, + userAuthReady = Promise.resolve(), + getUserAuth = () => null, + getScheduleService = () => null, + userToken, + logger = console, + setIntervalFn = setInterval, + clearIntervalFn = clearInterval, + now = Date.now, +} = {}) { + if ( + !app || + typeof userToken !== 'function' || + typeof setIntervalFn !== 'function' || + typeof clearIntervalFn !== 'function' || + typeof now !== 'function' + ) { + throw new Error( + 'attachPortalNotificationRoutes requires route dependencies', + ); + } + + app.get('/auth/notifications', async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const scheduleService = getScheduleService(); + if (!userAuth || !scheduleService) { + return res.status(503).json({ + message: '通知服务未启用', + }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const rawStatus = + typeof req.query?.status === 'string' + ? req.query.status + : 'unread'; + const status = [ + 'all', + 'unread', + 'read', + ].includes(rawStatus) + ? rawStatus + : 'all'; + const limit = Math.min( + Math.max(Number(req.query?.limit) || 20, 1), + 100, + ); + try { + const notifications = + await scheduleService.listUserNotifications({ + userId: me.id, + status, + limit, + }); + res.json({ notifications }); + } catch (error) { + logger.warn( + 'List user notifications failed:', + error instanceof Error + ? error.message + : error, + ); + res.status(500).json({ + message: '通知列表加载失败', + }); + } + }); + + app.get( + '/auth/notifications/events', + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const scheduleService = + getScheduleService(); + if (!userAuth || !scheduleService) { + return res.status(503).json({ + message: '通知服务未启用', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + + res.status(200); + res.setHeader( + 'Content-Type', + 'text/event-stream; charset=utf-8', + ); + res.setHeader( + 'Cache-Control', + 'no-cache, no-transform', + ); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders?.(); + + let closed = false; + let lastNotificationId = null; + + const sendEvent = (event, data) => { + if (closed || res.destroyed) return; + res.write(`event: ${event}\n`); + res.write( + `data: ${JSON.stringify(data)}\n\n`, + ); + }; + + const checkUnread = async () => { + if (closed) return; + try { + const notifications = + await scheduleService.listUserNotifications({ + userId: me.id, + status: 'unread', + limit: 1, + }); + const latest = notifications[0] ?? null; + const nextId = latest?.id ?? null; + if ( + nextId && + nextId !== lastNotificationId + ) { + lastNotificationId = nextId; + sendEvent('notification', { + notification: latest, + }); + } else if (!nextId) { + lastNotificationId = null; + } + } catch { + sendEvent('sync', { + reason: 'check_failed', + }); + } + }; + + sendEvent('ready', { ok: true }); + await checkUnread(); + + const checkTimer = setIntervalFn(() => { + void checkUnread(); + }, 2500); + const keepaliveTimer = setIntervalFn(() => { + sendEvent('ping', { at: now() }); + }, 25000); + + req.on('close', () => { + closed = true; + clearIntervalFn(checkTimer); + clearIntervalFn(keepaliveTimer); + }); + }, + ); + + app.post( + '/auth/notifications/:id/read', + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const scheduleService = + getScheduleService(); + if (!userAuth || !scheduleService) { + return res.status(503).json({ + message: '通知服务未启用', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const ok = + await scheduleService.markUserNotificationRead({ + userId: me.id, + notificationId: req.params.id, + }); + if (!ok) { + return res.status(404).json({ + message: '通知不存在或已读', + }); + } + res.json({ ok: true }); + }, + ); + + app.post( + '/auth/notifications/read-all', + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const scheduleService = + getScheduleService(); + if (!userAuth || !scheduleService) { + return res.status(503).json({ + message: '通知服务未启用', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const updated = + await scheduleService.markAllUserNotificationsRead( + { userId: me.id }, + ); + res.json({ + ok: true, + updated, + }); + }, + ); + + app.delete( + '/auth/notifications/:id', + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const scheduleService = + getScheduleService(); + if (!userAuth || !scheduleService) { + return res.status(503).json({ + message: '通知服务未启用', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const ok = + await scheduleService.deleteUserNotification({ + userId: me.id, + notificationId: req.params.id, + }); + if (!ok) { + return res.status(404).json({ + message: '通知不存在', + }); + } + res.status(204).end(); + }, + ); + + app.delete( + '/auth/notifications', + async (req, res) => { + await userAuthReady; + const userAuth = getUserAuth(); + const scheduleService = + getScheduleService(); + if (!userAuth || !scheduleService) { + return res.status(503).json({ + message: '通知服务未启用', + }); + } + const me = await userAuth.getMe( + userToken(req), + ); + if (!me) { + return res.status(401).json({ + message: '未登录', + }); + } + const status = + typeof req.query?.status === 'string' + ? req.query.status + : 'all'; + const deleted = + await scheduleService.clearUserNotifications({ + userId: me.id, + status, + }); + res.json({ + ok: true, + deleted, + }); + }, + ); +} diff --git a/server/portal-notification-routes.test.mjs b/server/portal-notification-routes.test.mjs new file mode 100644 index 0000000..c6a3c6b --- /dev/null +++ b/server/portal-notification-routes.test.mjs @@ -0,0 +1,468 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + attachPortalNotificationRoutes, +} from './portal-notification-routes.mjs'; + +function createResponse() { + return { + statusCode: 200, + headers: {}, + body: undefined, + ended: false, + destroyed: false, + writes: [], + flushed: false, + status(code) { + this.statusCode = code; + return this; + }, + setHeader(name, value) { + this.headers[name] = value; + }, + flushHeaders() { + this.flushed = true; + }, + json(body) { + this.body = body; + return this; + }, + write(chunk) { + this.writes.push(chunk); + return true; + }, + end() { + this.ended = true; + return this; + }, + }; +} + +function createSetup(overrides = {}) { + const routes = []; + const calls = []; + const timers = []; + const clearedTimers = []; + let notifications = [ + { + id: 'notification-1', + title: '提醒', + }, + ]; + let userAuth = { + async getMe(token) { + calls.push(['get-me', token]); + return { id: 'user-1' }; + }, + }; + let scheduleService = { + async listUserNotifications(options) { + calls.push(['list', options]); + return notifications; + }, + async markUserNotificationRead(options) { + calls.push(['mark-read', options]); + return true; + }, + async markAllUserNotificationsRead(options) { + calls.push(['mark-all-read', options]); + return 3; + }, + async deleteUserNotification(options) { + calls.push(['delete-one', options]); + return true; + }, + async clearUserNotifications(options) { + calls.push(['clear-all', options]); + return 4; + }, + }; + const app = { + get(path, ...handlers) { + routes.push({ + method: 'get', + path, + handlers, + }); + }, + post(path, ...handlers) { + routes.push({ + method: 'post', + path, + handlers, + }); + }, + delete(path, ...handlers) { + routes.push({ + method: 'delete', + path, + handlers, + }); + }, + }; + const options = { + app, + userAuthReady: Promise.resolve(), + getUserAuth: () => userAuth, + getScheduleService: () => scheduleService, + userToken: () => 'request-token', + logger: { + warn(...args) { + calls.push(['warn', ...args]); + }, + }, + setIntervalFn(callback, delay) { + const timer = { + callback, + delay, + id: `timer-${timers.length + 1}`, + }; + timers.push(timer); + return timer; + }, + clearIntervalFn(timer) { + clearedTimers.push(timer); + }, + now: () => 123456789, + ...overrides, + }; + attachPortalNotificationRoutes(options); + + return { + routes, + calls, + timers, + clearedTimers, + setNotifications(value) { + notifications = value; + }, + setUserAuth(value) { + userAuth = value; + }, + setScheduleService(value) { + scheduleService = value; + }, + route(method, path) { + return routes.find( + (route) => + route.method === method && + route.path === path, + ); + }, + }; +} + +function createRequest(overrides = {}) { + const listeners = {}; + return { + body: {}, + query: {}, + params: {}, + ip: '127.0.0.1', + on(event, handler) { + listeners[event] = handler; + }, + emit(event) { + listeners[event]?.(); + }, + ...overrides, + }; +} + +async function invoke( + setup, + method, + path, + req = createRequest(), +) { + const route = setup.route(method, path); + assert.ok( + route, + `${method.toUpperCase()} ${path}`, + ); + const res = createResponse(); + await route.handlers.at(-1)(req, res); + return res; +} + +test('registers the notification route inventory in order', () => { + const setup = createSetup(); + assert.deepEqual( + setup.routes.map(({ method, path }) => [ + method, + path, + ]), + [ + ['get', '/auth/notifications'], + ['get', '/auth/notifications/events'], + [ + 'post', + '/auth/notifications/:id/read', + ], + ['post', '/auth/notifications/read-all'], + ['delete', '/auth/notifications/:id'], + ['delete', '/auth/notifications'], + ], + ); +}); + +test('preserves notification list filters, limits, and errors', async () => { + const setup = createSetup(); + const res = await invoke( + setup, + 'get', + '/auth/notifications', + createRequest({ + query: { + status: 'unexpected', + limit: '500', + }, + }), + ); + assert.deepEqual(res.body, { + notifications: [ + { + id: 'notification-1', + title: '提醒', + }, + ], + }); + assert.deepEqual( + setup.calls.find( + ([name]) => name === 'list', + ), + [ + 'list', + { + userId: 'user-1', + status: 'all', + limit: 100, + }, + ], + ); + + const failing = createSetup(); + failing.setScheduleService({ + async listUserNotifications() { + throw new Error('database down'); + }, + }); + const failingRes = await invoke( + failing, + 'get', + '/auth/notifications', + ); + assert.equal(failingRes.statusCode, 500); + assert.deepEqual(failingRes.body, { + message: '通知列表加载失败', + }); +}); + +test('preserves notification availability and authentication gates', async () => { + const unavailable = createSetup(); + unavailable.setScheduleService(null); + const unavailableRes = await invoke( + unavailable, + 'get', + '/auth/notifications', + ); + assert.equal(unavailableRes.statusCode, 503); + + const unauthorized = createSetup(); + unauthorized.setUserAuth({ + async getMe() { + return null; + }, + }); + const unauthorizedRes = await invoke( + unauthorized, + 'get', + '/auth/notifications', + ); + assert.equal(unauthorizedRes.statusCode, 401); +}); + +test('preserves SSE ready, notification dedupe, polling, heartbeat, and cleanup', async () => { + const setup = createSetup(); + const req = createRequest(); + const res = await invoke( + setup, + 'get', + '/auth/notifications/events', + req, + ); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.headers, { + 'Content-Type': + 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }); + assert.equal(res.flushed, true); + assert.match( + res.writes.join(''), + /event: ready[\s\S]*event: notification/, + ); + assert.deepEqual( + setup.timers.map(({ delay }) => delay), + [2500, 25000], + ); + + const beforeDuplicate = res.writes.length; + setup.timers[0].callback(); + await new Promise((resolve) => + setImmediate(resolve), + ); + assert.equal( + res.writes.length, + beforeDuplicate, + ); + + setup.setNotifications([ + { + id: 'notification-2', + title: '新提醒', + }, + ]); + setup.timers[0].callback(); + await new Promise((resolve) => + setImmediate(resolve), + ); + assert.match( + res.writes.join(''), + /notification-2/, + ); + + setup.timers[1].callback(); + assert.match( + res.writes.join(''), + /event: ping[\s\S]*123456789/, + ); + + const beforeClose = res.writes.length; + req.emit('close'); + assert.deepEqual( + setup.clearedTimers, + setup.timers, + ); + setup.timers[1].callback(); + assert.equal(res.writes.length, beforeClose); +}); + +test('preserves SSE sync fallback when unread checks fail', async () => { + const setup = createSetup(); + setup.setScheduleService({ + async listUserNotifications() { + throw new Error('temporary failure'); + }, + }); + const res = await invoke( + setup, + 'get', + '/auth/notifications/events', + createRequest(), + ); + assert.match( + res.writes.join(''), + /event: sync[\s\S]*check_failed/, + ); + assert.equal(setup.timers.length, 2); +}); + +test('preserves single and bulk read operations', async () => { + const setup = createSetup(); + const single = await invoke( + setup, + 'post', + '/auth/notifications/:id/read', + createRequest({ + params: { id: 'notification-1' }, + }), + ); + assert.deepEqual(single.body, { ok: true }); + assert.ok( + setup.calls.some( + (call) => + call[0] === 'mark-read' && + call[1].userId === 'user-1' && + call[1].notificationId === + 'notification-1', + ), + ); + + const bulk = await invoke( + setup, + 'post', + '/auth/notifications/read-all', + ); + assert.deepEqual(bulk.body, { + ok: true, + updated: 3, + }); + + const missing = createSetup(); + missing.setScheduleService({ + async markUserNotificationRead() { + return false; + }, + }); + const missingRes = await invoke( + missing, + 'post', + '/auth/notifications/:id/read', + createRequest({ + params: { id: 'missing' }, + }), + ); + assert.equal(missingRes.statusCode, 404); +}); + +test('preserves single deletion and scoped clear operations', async () => { + const setup = createSetup(); + const single = await invoke( + setup, + 'delete', + '/auth/notifications/:id', + createRequest({ + params: { id: 'notification-1' }, + }), + ); + assert.equal(single.statusCode, 204); + assert.equal(single.ended, true); + + const cleared = await invoke( + setup, + 'delete', + '/auth/notifications', + createRequest({ + query: { status: 'read' }, + }), + ); + assert.deepEqual(cleared.body, { + ok: true, + deleted: 4, + }); + assert.ok( + setup.calls.some( + (call) => + call[0] === 'clear-all' && + call[1].status === 'read', + ), + ); + + const missing = createSetup(); + missing.setScheduleService({ + async deleteUserNotification() { + return false; + }, + }); + const missingRes = await invoke( + missing, + 'delete', + '/auth/notifications/:id', + createRequest({ + params: { id: 'missing' }, + }), + ); + assert.equal(missingRes.statusCode, 404); +}); diff --git a/server/portal-plaza-discovery-routes.mjs b/server/portal-plaza-discovery-routes.mjs new file mode 100644 index 0000000..9c24035 --- /dev/null +++ b/server/portal-plaza-discovery-routes.mjs @@ -0,0 +1,64 @@ +function assertRouter(api) { + if (!api || typeof api.get !== 'function') { + throw new Error('attachPortalPlazaDiscoveryRoutes requires an Express-compatible router'); + } +} + +function defaultSendData(res, _req, data) { + return res.json(data); +} + +function defaultSendError(res, _req, status, code, message) { + return res.status(status).json({ error: { code, message } }); +} + +function defaultRouteError(_res, _req, error) { + throw error; +} + +export function attachPortalPlazaDiscoveryRoutes( + api, + { + getPlazaPosts = () => null, + getPlazaSeo = () => null, + sendData = defaultSendData, + sendError = defaultSendError, + handleRouteError = defaultRouteError, + } = {}, +) { + assertRouter(api); + + api.get('/plaza/v1/categories', async (req, res) => { + const plazaPosts = getPlazaPosts(); + if (!plazaPosts) { + sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + return; + } + try { + return sendData(res, req, { categories: await plazaPosts.listCategories() }); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.get('/plaza/v1/seo/sitemap', async (req, res) => { + const plazaPosts = getPlazaPosts(); + if (!plazaPosts) { + sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + return; + } + const plazaSeo = getPlazaSeo(); + if (!plazaSeo) { + return sendError(res, req, 503, 'plaza_unavailable', 'Plaza SEO 未启用'); + } + try { + const data = await plazaSeo.listSitemapData({ + postLimit: req.query.post_limit, + userLimit: req.query.user_limit, + }); + return sendData(res, req, data); + } catch (error) { + return handleRouteError(res, req, error); + } + }); +} diff --git a/server/portal-plaza-discovery-routes.test.mjs b/server/portal-plaza-discovery-routes.test.mjs new file mode 100644 index 0000000..0b31408 --- /dev/null +++ b/server/portal-plaza-discovery-routes.test.mjs @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalPlazaDiscoveryRoutes } from './portal-plaza-discovery-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + return { + routes, + get(path, handler) { + routes.set(path, handler); + }, + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +function createRouteDependencies(overrides = {}) { + const calls = { + data: [], + errors: [], + routeErrors: [], + }; + return { + calls, + dependencies: { + sendData(res, req, data) { + calls.data.push({ res, req, data }); + return res.json({ data }); + }, + sendError(res, req, status, code, message) { + calls.errors.push({ res, req, status, code, message }); + return res.status(status).json({ error: { code, message } }); + }, + handleRouteError(res, req, error) { + calls.routeErrors.push({ res, req, error }); + return res.status(418).json({ mapped: error.message }); + }, + ...overrides, + }, + }; +} + +test('portal Plaza discovery module preserves route inventory and order', () => { + const api = createRouterRecorder(); + attachPortalPlazaDiscoveryRoutes(api); + assert.deepEqual( + [...api.routes.keys()], + ['/plaza/v1/categories', '/plaza/v1/seo/sitemap'], + ); +}); + +test('GET /plaza/v1/categories preserves Plaza unavailable response', async () => { + const api = createRouterRecorder(); + const { calls, dependencies } = createRouteDependencies(); + attachPortalPlazaDiscoveryRoutes(api, dependencies); + const req = {}; + const res = createResponseRecorder(); + + await api.routes.get('/plaza/v1/categories')(req, res); + + assert.equal(res.statusCode, 503); + assert.deepEqual(res.body, { + error: { code: 'plaza_unavailable', message: 'Plaza 未启用' }, + }); + assert.deepEqual(calls.errors[0], { + res, + req, + status: 503, + code: 'plaza_unavailable', + message: 'Plaza 未启用', + }); +}); + +test('GET /plaza/v1/categories preserves successful response envelope', async () => { + const api = createRouterRecorder(); + const categories = [{ slug: 'technology', name: '科技' }]; + const { calls, dependencies } = createRouteDependencies({ + getPlazaPosts: () => ({ + async listCategories() { + return categories; + }, + }), + }); + attachPortalPlazaDiscoveryRoutes(api, dependencies); + const req = {}; + const res = createResponseRecorder(); + + await api.routes.get('/plaza/v1/categories')(req, res); + + assert.deepEqual(calls.data[0], { + res, + req, + data: { categories }, + }); + assert.deepEqual(res.body, { data: { categories } }); +}); + +test('GET /plaza/v1/categories preserves route error mapping', async () => { + const api = createRouterRecorder(); + const failure = new Error('category query failed'); + const { calls, dependencies } = createRouteDependencies({ + getPlazaPosts: () => ({ + async listCategories() { + throw failure; + }, + }), + }); + attachPortalPlazaDiscoveryRoutes(api, dependencies); + const req = {}; + const res = createResponseRecorder(); + + await api.routes.get('/plaza/v1/categories')(req, res); + + assert.deepEqual(calls.routeErrors[0], { res, req, error: failure }); + assert.equal(res.statusCode, 418); + assert.deepEqual(res.body, { mapped: 'category query failed' }); +}); + +test('GET /plaza/v1/seo/sitemap preserves Plaza and SEO availability gates', async () => { + const plazaUnavailableApi = createRouterRecorder(); + const plazaUnavailable = createRouteDependencies(); + attachPortalPlazaDiscoveryRoutes(plazaUnavailableApi, plazaUnavailable.dependencies); + const plazaUnavailableRes = createResponseRecorder(); + await plazaUnavailableApi.routes.get('/plaza/v1/seo/sitemap')( + { query: {} }, + plazaUnavailableRes, + ); + assert.equal(plazaUnavailableRes.statusCode, 503); + assert.equal(plazaUnavailable.calls.errors[0].message, 'Plaza 未启用'); + + const seoUnavailableApi = createRouterRecorder(); + const seoUnavailable = createRouteDependencies({ + getPlazaPosts: () => ({}), + }); + attachPortalPlazaDiscoveryRoutes(seoUnavailableApi, seoUnavailable.dependencies); + const seoUnavailableRes = createResponseRecorder(); + await seoUnavailableApi.routes.get('/plaza/v1/seo/sitemap')( + { query: {} }, + seoUnavailableRes, + ); + assert.equal(seoUnavailableRes.statusCode, 503); + assert.equal(seoUnavailable.calls.errors[0].message, 'Plaza SEO 未启用'); +}); + +test('GET /plaza/v1/seo/sitemap preserves query forwarding and response envelope', async () => { + const api = createRouterRecorder(); + const sitemap = { categories: [], posts: [{ id: 'post-1' }], users: [] }; + let receivedLimits = null; + const { calls, dependencies } = createRouteDependencies({ + getPlazaPosts: () => ({}), + getPlazaSeo: () => ({ + async listSitemapData(limits) { + receivedLimits = limits; + return sitemap; + }, + }), + }); + attachPortalPlazaDiscoveryRoutes(api, dependencies); + const req = { query: { post_limit: '25', user_limit: '10' } }; + const res = createResponseRecorder(); + + await api.routes.get('/plaza/v1/seo/sitemap')(req, res); + + assert.deepEqual(receivedLimits, { postLimit: '25', userLimit: '10' }); + assert.deepEqual(calls.data[0], { res, req, data: sitemap }); + assert.deepEqual(res.body, { data: sitemap }); +}); + +test('GET /plaza/v1/seo/sitemap preserves route error mapping', async () => { + const api = createRouterRecorder(); + const failure = new Error('sitemap query failed'); + const { calls, dependencies } = createRouteDependencies({ + getPlazaPosts: () => ({}), + getPlazaSeo: () => ({ + async listSitemapData() { + throw failure; + }, + }), + }); + attachPortalPlazaDiscoveryRoutes(api, dependencies); + const req = { query: {} }; + const res = createResponseRecorder(); + + await api.routes.get('/plaza/v1/seo/sitemap')(req, res); + + assert.deepEqual(calls.routeErrors[0], { res, req, error: failure }); + assert.equal(res.statusCode, 418); + assert.deepEqual(res.body, { mapped: 'sitemap query failed' }); +}); diff --git a/server/portal-plaza-routes.mjs b/server/portal-plaza-routes.mjs new file mode 100644 index 0000000..a311015 --- /dev/null +++ b/server/portal-plaza-routes.mjs @@ -0,0 +1,406 @@ +import crypto from 'node:crypto'; +import { parseCookies } from '../auth.mjs'; + +const PLAZA_SID_COOKIE = 'plaza_sid'; + +function assertRouter(api) { + if ( + !api || + typeof api.get !== 'function' || + typeof api.post !== 'function' || + typeof api.patch !== 'function' || + typeof api.delete !== 'function' + ) { + throw new Error( + 'attachPortalPlazaRoutes requires an Express-compatible router', + ); + } +} + +function reactionEventType(type) { + if (type === 'like' || type === 'collect' || type === 'share') return type; + return null; +} + +export function attachPortalPlazaRoutes( + api, + { + getPlazaSeo = () => null, + getPlazaOps = () => null, + getPlazaPosts = () => null, + getPlazaEvents = () => null, + getPlazaInteractions = () => null, + getPlazaRedis = () => null, + resolveClientIp = (req) => req.ip, + randomUUID = crypto.randomUUID, + sendData = (res, _req, data, status = 200) => + res.status(status).json({ data }), + sendError = (res, _req, status, code, message, details) => + res.status(status).json({ error: { code, message, details } }), + handleRouteError = (_res, _req, error) => { + throw error; + }, + } = {}, +) { + assertRouter(api); + + const resolveSessionId = (req, res) => { + const cookies = parseCookies(req.get('cookie')); + let sessionId = cookies[PLAZA_SID_COOKIE]; + if (!sessionId) { + sessionId = randomUUID(); + res.append( + 'Set-Cookie', + `${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`, + ); + } + return sessionId; + }; + + const recordEventsAsync = (req, res, events) => { + const plazaEvents = getPlazaEvents(); + if (!plazaEvents || !Array.isArray(events) || events.length === 0) return; + const sessionId = resolveSessionId(req, res); + void plazaEvents + .recordEvents({ + userId: req.currentUser?.id ?? null, + sessionId, + events, + }) + .catch(() => {}); + }; + + const requirePosts = (res, req) => { + const plazaPosts = getPlazaPosts(); + if (!plazaPosts) { + sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + return null; + } + return plazaPosts; + }; + + const requireInteractions = (res, req) => { + const plazaInteractions = getPlazaInteractions(); + if (!plazaInteractions) { + sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + return null; + } + return plazaInteractions; + }; + + const requireCurrentUser = (req, res) => { + if (req.currentUser) return req.currentUser; + sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + return null; + }; + + api.post('/plaza/v1/attribution/events', async (req, res) => { + const plazaSeo = getPlazaSeo(); + if (!plazaSeo) { + return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + } + try { + const result = await plazaSeo.recordAttribution( + req.body ?? {}, + resolveClientIp(req), + ); + return sendData(res, req, result, 201); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + for (const [route, targetType] of [ + ['/plaza/v1/posts/:id/reports', 'post'], + ['/plaza/v1/comments/:id/reports', 'comment'], + ]) { + api.post(route, async (req, res) => { + const plazaOps = getPlazaOps(); + if (!plazaOps) { + return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + } + const user = requireCurrentUser(req, res); + if (!user) return; + try { + const report = await plazaOps.createReport(user.id, { + target_type: targetType, + target_id: req.params.id, + reason: req.body?.reason, + detail: req.body?.detail, + }); + return sendData(res, req, { report }, 201); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + } + + api.get('/plaza/v1/feed', async (req, res) => { + const plazaPosts = requirePosts(res, req); + if (!plazaPosts) return; + try { + const sessionId = resolveSessionId(req, res); + const feed = await plazaPosts.listFeed({ + sort: req.query.sort, + categorySlug: req.query.category ?? null, + cursor: req.query.cursor ?? null, + limit: req.query.limit, + viewerId: req.currentUser?.id ?? null, + sessionId, + }); + return sendData(res, req, feed); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.post('/plaza/v1/events', async (req, res) => { + const plazaPosts = requirePosts(res, req); + if (!plazaPosts) return; + const plazaEvents = getPlazaEvents(); + if (!plazaEvents) { + return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + } + try { + const sessionId = + String(req.body?.session_id ?? '').trim() || + resolveSessionId(req, res); + const result = await plazaEvents.recordEvents({ + userId: req.currentUser?.id ?? null, + sessionId, + events: req.body?.events ?? [], + }); + return sendData( + res, + req, + { ...result, session_id: sessionId }, + 201, + ); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.post('/plaza/v1/posts/:id/reactions', async (req, res) => { + const plazaInteractions = requireInteractions(res, req); + if (!plazaInteractions) return; + const user = requireCurrentUser(req, res); + if (!user) return; + try { + const result = await plazaInteractions.addReaction( + user.id, + req.params.id, + req.body?.type, + ); + const eventType = reactionEventType(result.type); + if (eventType) { + recordEventsAsync(req, res, [ + { event_type: eventType, post_id: req.params.id }, + ]); + } + return sendData(res, req, result); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => { + const plazaInteractions = requireInteractions(res, req); + if (!plazaInteractions) return; + const user = requireCurrentUser(req, res); + if (!user) return; + try { + const result = await plazaInteractions.removeReaction( + user.id, + req.params.id, + req.params.type, + ); + return sendData(res, req, result); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.get('/plaza/v1/posts/:id/comments', async (req, res) => { + const plazaInteractions = requireInteractions(res, req); + if (!plazaInteractions) return; + try { + const comments = await plazaInteractions.listComments(req.params.id, { + cursor: req.query.cursor ?? null, + limit: req.query.limit, + parentId: req.query.parent_id ?? null, + viewerId: req.currentUser?.id ?? null, + }); + return sendData(res, req, comments); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.post('/plaza/v1/posts/:id/comments', async (req, res) => { + const plazaInteractions = requireInteractions(res, req); + if (!plazaInteractions) return; + const user = requireCurrentUser(req, res); + if (!user) return; + try { + const comment = await plazaInteractions.createComment( + user.id, + req.params.id, + req.body ?? {}, + ); + recordEventsAsync(req, res, [ + { event_type: 'comment', post_id: req.params.id }, + ]); + return sendData(res, req, { comment }, 201); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.delete('/plaza/v1/comments/:id', async (req, res) => { + const plazaInteractions = requireInteractions(res, req); + if (!plazaInteractions) return; + const user = requireCurrentUser(req, res); + if (!user) return; + try { + const comment = await plazaInteractions.deleteComment( + user.id, + req.params.id, + ); + return sendData(res, req, { comment }); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.post('/plaza/v1/comments/:id/reactions', async (req, res) => { + const plazaInteractions = requireInteractions(res, req); + if (!plazaInteractions) return; + const user = requireCurrentUser(req, res); + if (!user) return; + try { + const liked = req.body?.liked !== false; + const result = await plazaInteractions.toggleCommentLike( + user.id, + req.params.id, + liked, + ); + return sendData(res, req, result); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.get('/plaza/v1/users/:slug', async (req, res) => { + const plazaInteractions = requireInteractions(res, req); + if (!plazaInteractions) return; + try { + const profile = await plazaInteractions.getUserProfile( + req.params.slug, + req.currentUser?.id ?? null, + ); + return sendData(res, req, profile); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.get('/plaza/v1/users/:slug/posts', async (req, res) => { + const plazaInteractions = requireInteractions(res, req); + if (!plazaInteractions) return; + try { + const feed = await plazaInteractions.listUserPosts(req.params.slug, { + cursor: req.query.cursor ?? null, + limit: req.query.limit, + viewerId: req.currentUser?.id ?? null, + }); + return sendData(res, req, feed); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + for (const [route, operation] of [ + ['/plaza/v1/users/:slug/follow', 'followUser'], + ['/plaza/v1/users/:slug/follow', 'unfollowUser'], + ]) { + const register = operation === 'followUser' ? api.post.bind(api) : api.delete.bind(api); + register(route, async (req, res) => { + const plazaInteractions = requireInteractions(res, req); + if (!plazaInteractions) return; + const user = requireCurrentUser(req, res); + if (!user) return; + try { + const result = await plazaInteractions[operation]( + user.id, + req.params.slug, + ); + return sendData(res, req, result); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + } + + api.get('/plaza/v1/posts/:id', async (req, res) => { + const plazaPosts = requirePosts(res, req); + if (!plazaPosts) return; + try { + const post = await plazaPosts.getPostById(req.params.id, { + viewerId: req.currentUser?.id ?? null, + }); + void getPlazaRedis() + .recordView(req.params.id, resolveClientIp(req)) + .catch(() => {}); + recordEventsAsync(req, res, [ + { event_type: 'view', post_id: req.params.id }, + ]); + return sendData(res, req, { post }); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.post('/plaza/v1/posts', async (req, res) => { + const plazaPosts = requirePosts(res, req); + if (!plazaPosts) return; + const user = requireCurrentUser(req, res); + if (!user) return; + try { + const post = await plazaPosts.createPost(user.id, req.body ?? {}); + return sendData(res, req, { post }, 201); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.patch('/plaza/v1/posts/:id', async (req, res) => { + const plazaPosts = requirePosts(res, req); + if (!plazaPosts) return; + const user = requireCurrentUser(req, res); + if (!user) return; + try { + const post = await plazaPosts.updatePost( + user.id, + req.params.id, + req.body ?? {}, + ); + return sendData(res, req, { post }); + } catch (error) { + return handleRouteError(res, req, error); + } + }); + + api.delete('/plaza/v1/posts/:id', async (req, res) => { + const plazaPosts = requirePosts(res, req); + if (!plazaPosts) return; + const user = requireCurrentUser(req, res); + if (!user) return; + try { + const post = await plazaPosts.hidePost(user.id, req.params.id); + return sendData(res, req, { post }); + } catch (error) { + return handleRouteError(res, req, error); + } + }); +} diff --git a/server/portal-plaza-routes.test.mjs b/server/portal-plaza-routes.test.mjs new file mode 100644 index 0000000..03cbd08 --- /dev/null +++ b/server/portal-plaza-routes.test.mjs @@ -0,0 +1,637 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachPortalPlazaRoutes } from './portal-plaza-routes.mjs'; + +function createRouterRecorder() { + const routes = new Map(); + const register = (method) => (path, handler) => { + routes.set(`${method} ${path}`, handler); + }; + return { + routes, + get: register('GET'), + post: register('POST'), + patch: register('PATCH'), + delete: register('DELETE'), + }; +} + +function createResponseRecorder() { + return { + statusCode: 200, + body: undefined, + headers: {}, + appended: [], + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + append(name, value) { + this.appended.push([name, value]); + return this; + }, + }; +} + +function createRequest(overrides = {}) { + const headers = overrides.headers ?? {}; + return { + body: {}, + query: {}, + params: {}, + currentUser: null, + ip: '127.0.0.1', + headers, + get(name) { + return headers[String(name).toLowerCase()]; + }, + ...overrides, + headers, + }; +} + +function createDependencies(overrides = {}) { + const calls = { + data: [], + errors: [], + routeErrors: [], + }; + return { + calls, + dependencies: { + sendData(res, req, data, status = 200) { + calls.data.push({ res, req, data, status }); + return res.status(status).json({ data }); + }, + sendError(res, req, status, code, message, details) { + calls.errors.push({ res, req, status, code, message, details }); + return res.status(status).json({ error: { code, message, details } }); + }, + handleRouteError(res, req, error) { + calls.routeErrors.push({ res, req, error }); + return res.status(418).json({ mapped: error.message }); + }, + ...overrides, + }, + }; +} + +test('Plaza module preserves all remaining route registrations and order', () => { + const api = createRouterRecorder(); + attachPortalPlazaRoutes(api); + assert.deepEqual([...api.routes.keys()], [ + 'POST /plaza/v1/attribution/events', + 'POST /plaza/v1/posts/:id/reports', + 'POST /plaza/v1/comments/:id/reports', + 'GET /plaza/v1/feed', + 'POST /plaza/v1/events', + 'POST /plaza/v1/posts/:id/reactions', + 'DELETE /plaza/v1/posts/:id/reactions/:type', + 'GET /plaza/v1/posts/:id/comments', + 'POST /plaza/v1/posts/:id/comments', + 'DELETE /plaza/v1/comments/:id', + 'POST /plaza/v1/comments/:id/reactions', + 'GET /plaza/v1/users/:slug', + 'GET /plaza/v1/users/:slug/posts', + 'POST /plaza/v1/users/:slug/follow', + 'DELETE /plaza/v1/users/:slug/follow', + 'GET /plaza/v1/posts/:id', + 'POST /plaza/v1/posts', + 'PATCH /plaza/v1/posts/:id', + 'DELETE /plaza/v1/posts/:id', + ]); +}); + +test('attribution and reports preserve IP, target mapping, auth, and 201 status', async () => { + const calls = []; + const api = createRouterRecorder(); + const deps = createDependencies({ + getPlazaSeo: () => ({ + async recordAttribution(body, ip) { + calls.push({ kind: 'attribution', body, ip }); + return { recorded: true }; + }, + }), + getPlazaOps: () => ({ + async createReport(userId, input) { + calls.push({ kind: 'report', userId, input }); + return { id: `report-${input.target_type}` }; + }, + }), + resolveClientIp: () => '203.0.113.10', + }); + attachPortalPlazaRoutes(api, deps.dependencies); + + await api.routes.get('POST /plaza/v1/attribution/events')( + createRequest({ body: { source: 'search' } }), + createResponseRecorder(), + ); + const deniedRes = createResponseRecorder(); + await api.routes.get('POST /plaza/v1/posts/:id/reports')( + createRequest({ params: { id: 'post-1' } }), + deniedRes, + ); + assert.equal(deniedRes.statusCode, 401); + + await api.routes.get('POST /plaza/v1/posts/:id/reports')( + createRequest({ + currentUser: { id: 'user-1' }, + params: { id: 'post-1' }, + body: { reason: 'spam', detail: 'details' }, + }), + createResponseRecorder(), + ); + await api.routes.get('POST /plaza/v1/comments/:id/reports')( + createRequest({ + currentUser: { id: 'user-1' }, + params: { id: 'comment-1' }, + body: { reason: 'abuse' }, + }), + createResponseRecorder(), + ); + + assert.deepEqual(calls, [ + { + kind: 'attribution', + body: { source: 'search' }, + ip: '203.0.113.10', + }, + { + kind: 'report', + userId: 'user-1', + input: { + target_type: 'post', + target_id: 'post-1', + reason: 'spam', + detail: 'details', + }, + }, + { + kind: 'report', + userId: 'user-1', + input: { + target_type: 'comment', + target_id: 'comment-1', + reason: 'abuse', + detail: undefined, + }, + }, + ]); + assert.deepEqual( + deps.calls.data.map((item) => item.status), + [201, 201, 201], + ); +}); + +test('feed and events preserve optional user, session cookie, query, and explicit session id', async () => { + const calls = []; + const api = createRouterRecorder(); + const deps = createDependencies({ + getPlazaPosts: () => ({ + async listFeed(input) { + calls.push({ kind: 'feed', input }); + return { items: [] }; + }, + }), + getPlazaEvents: () => ({ + async recordEvents(input) { + calls.push({ kind: 'events', input }); + return { accepted: input.events.length }; + }, + }), + randomUUID: () => 'generated-session', + }); + attachPortalPlazaRoutes(api, deps.dependencies); + + const feedRes = createResponseRecorder(); + await api.routes.get('GET /plaza/v1/feed')( + createRequest({ + currentUser: { id: 'user-1' }, + query: { + sort: 'hot', + category: 'tech', + cursor: 'cursor-1', + limit: '20', + }, + headers: {}, + }), + feedRes, + ); + assert.deepEqual(feedRes.appended, [ + [ + 'Set-Cookie', + 'plaza_sid=generated-session; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly', + ], + ]); + + const explicitRes = createResponseRecorder(); + await api.routes.get('POST /plaza/v1/events')( + createRequest({ + body: { + session_id: 'explicit-session', + events: [{ event_type: 'view' }], + }, + }), + explicitRes, + ); + assert.deepEqual(explicitRes.appended, []); + assert.deepEqual(calls, [ + { + kind: 'feed', + input: { + sort: 'hot', + categorySlug: 'tech', + cursor: 'cursor-1', + limit: '20', + viewerId: 'user-1', + sessionId: 'generated-session', + }, + }, + { + kind: 'events', + input: { + userId: null, + sessionId: 'explicit-session', + events: [{ event_type: 'view' }], + }, + }, + ]); + assert.deepEqual(deps.calls.data[1].data, { + accepted: 1, + session_id: 'explicit-session', + }); + assert.equal(deps.calls.data[1].status, 201); +}); + +test('post reactions and comments preserve auth, payloads, and async event recording', async () => { + const calls = []; + const interactions = { + async addReaction(...args) { + calls.push({ kind: 'addReaction', args }); + return { type: 'like', active: true }; + }, + async removeReaction(...args) { + calls.push({ kind: 'removeReaction', args }); + return { active: false }; + }, + async listComments(...args) { + calls.push({ kind: 'listComments', args }); + return { items: [] }; + }, + async createComment(...args) { + calls.push({ kind: 'createComment', args }); + return { id: 'comment-1' }; + }, + }; + const api = createRouterRecorder(); + const deps = createDependencies({ + getPlazaInteractions: () => interactions, + getPlazaEvents: () => ({ + async recordEvents(input) { + calls.push({ kind: 'recordEvents', input }); + return {}; + }, + }), + randomUUID: () => 'session-1', + }); + attachPortalPlazaRoutes(api, deps.dependencies); + + const deniedRes = createResponseRecorder(); + await api.routes.get('POST /plaza/v1/posts/:id/reactions')( + createRequest({ params: { id: 'post-1' } }), + deniedRes, + ); + assert.equal(deniedRes.statusCode, 401); + + const userReq = { + currentUser: { id: 'user-1' }, + headers: { cookie: 'plaza_sid=cookie-session' }, + }; + await api.routes.get('POST /plaza/v1/posts/:id/reactions')( + createRequest({ + ...userReq, + params: { id: 'post-1' }, + body: { type: 'like' }, + }), + createResponseRecorder(), + ); + await api.routes.get('DELETE /plaza/v1/posts/:id/reactions/:type')( + createRequest({ + ...userReq, + params: { id: 'post-1', type: 'like' }, + }), + createResponseRecorder(), + ); + await api.routes.get('GET /plaza/v1/posts/:id/comments')( + createRequest({ + ...userReq, + params: { id: 'post-1' }, + query: { cursor: 'c1', limit: '5', parent_id: 'parent-1' }, + }), + createResponseRecorder(), + ); + await api.routes.get('POST /plaza/v1/posts/:id/comments')( + createRequest({ + ...userReq, + params: { id: 'post-1' }, + body: { content: 'hello' }, + }), + createResponseRecorder(), + ); + await Promise.resolve(); + + assert.deepEqual(calls, [ + { + kind: 'addReaction', + args: ['user-1', 'post-1', 'like'], + }, + { + kind: 'recordEvents', + input: { + userId: 'user-1', + sessionId: 'cookie-session', + events: [{ event_type: 'like', post_id: 'post-1' }], + }, + }, + { + kind: 'removeReaction', + args: ['user-1', 'post-1', 'like'], + }, + { + kind: 'listComments', + args: [ + 'post-1', + { + cursor: 'c1', + limit: '5', + parentId: 'parent-1', + viewerId: 'user-1', + }, + ], + }, + { + kind: 'createComment', + args: ['user-1', 'post-1', { content: 'hello' }], + }, + { + kind: 'recordEvents', + input: { + userId: 'user-1', + sessionId: 'cookie-session', + events: [{ event_type: 'comment', post_id: 'post-1' }], + }, + }, + ]); +}); + +test('comment mutation routes preserve delete and liked default mapping', async () => { + const calls = []; + const api = createRouterRecorder(); + const deps = createDependencies({ + getPlazaInteractions: () => ({ + async deleteComment(...args) { + calls.push({ kind: 'delete', args }); + return { id: 'comment-1', deleted: true }; + }, + async toggleCommentLike(...args) { + calls.push({ kind: 'like', args }); + return { liked: args[2] }; + }, + }), + }); + attachPortalPlazaRoutes(api, deps.dependencies); + const base = { + currentUser: { id: 'user-1' }, + params: { id: 'comment-1' }, + }; + await api.routes.get('DELETE /plaza/v1/comments/:id')( + createRequest(base), + createResponseRecorder(), + ); + await api.routes.get('POST /plaza/v1/comments/:id/reactions')( + createRequest({ ...base, body: {} }), + createResponseRecorder(), + ); + await api.routes.get('POST /plaza/v1/comments/:id/reactions')( + createRequest({ ...base, body: { liked: false } }), + createResponseRecorder(), + ); + assert.deepEqual(calls, [ + { kind: 'delete', args: ['user-1', 'comment-1'] }, + { kind: 'like', args: ['user-1', 'comment-1', true] }, + { kind: 'like', args: ['user-1', 'comment-1', false] }, + ]); +}); + +test('profile, user posts, follow, and unfollow preserve optional viewer mapping', async () => { + const calls = []; + const api = createRouterRecorder(); + const deps = createDependencies({ + getPlazaInteractions: () => ({ + async getUserProfile(...args) { + calls.push({ kind: 'profile', args }); + return { slug: args[0] }; + }, + async listUserPosts(...args) { + calls.push({ kind: 'posts', args }); + return { items: [] }; + }, + async followUser(...args) { + calls.push({ kind: 'follow', args }); + return { following: true }; + }, + async unfollowUser(...args) { + calls.push({ kind: 'unfollow', args }); + return { following: false }; + }, + }), + }); + attachPortalPlazaRoutes(api, deps.dependencies); + + await api.routes.get('GET /plaza/v1/users/:slug')( + createRequest({ params: { slug: 'alice' } }), + createResponseRecorder(), + ); + await api.routes.get('GET /plaza/v1/users/:slug/posts')( + createRequest({ + currentUser: { id: 'viewer-1' }, + params: { slug: 'alice' }, + query: { cursor: 'c1', limit: '9' }, + }), + createResponseRecorder(), + ); + await api.routes.get('POST /plaza/v1/users/:slug/follow')( + createRequest({ + currentUser: { id: 'viewer-1' }, + params: { slug: 'alice' }, + }), + createResponseRecorder(), + ); + await api.routes.get('DELETE /plaza/v1/users/:slug/follow')( + createRequest({ + currentUser: { id: 'viewer-1' }, + params: { slug: 'alice' }, + }), + createResponseRecorder(), + ); + assert.deepEqual(calls, [ + { kind: 'profile', args: ['alice', null] }, + { + kind: 'posts', + args: [ + 'alice', + { cursor: 'c1', limit: '9', viewerId: 'viewer-1' }, + ], + }, + { kind: 'follow', args: ['viewer-1', 'alice'] }, + { kind: 'unfollow', args: ['viewer-1', 'alice'] }, + ]); +}); + +test('post detail and CRUD preserve viewer, view/event side effects, auth, and statuses', async () => { + const calls = []; + const api = createRouterRecorder(); + const deps = createDependencies({ + getPlazaPosts: () => ({ + async getPostById(...args) { + calls.push({ kind: 'get', args }); + return { id: args[0] }; + }, + async createPost(...args) { + calls.push({ kind: 'create', args }); + return { id: 'post-new' }; + }, + async updatePost(...args) { + calls.push({ kind: 'update', args }); + return { id: args[1] }; + }, + async hidePost(...args) { + calls.push({ kind: 'hide', args }); + return { id: args[1], hidden: true }; + }, + }), + getPlazaRedis: () => ({ + async recordView(...args) { + calls.push({ kind: 'view', args }); + }, + }), + getPlazaEvents: () => ({ + async recordEvents(input) { + calls.push({ kind: 'events', input }); + return {}; + }, + }), + resolveClientIp: () => '198.51.100.8', + randomUUID: () => 'session-view', + }); + attachPortalPlazaRoutes(api, deps.dependencies); + await api.routes.get('GET /plaza/v1/posts/:id')( + createRequest({ + currentUser: { id: 'viewer-1' }, + params: { id: 'post-1' }, + }), + createResponseRecorder(), + ); + await Promise.resolve(); + + const deniedRes = createResponseRecorder(); + await api.routes.get('POST /plaza/v1/posts')( + createRequest({ body: { title: 'Denied' } }), + deniedRes, + ); + assert.equal(deniedRes.statusCode, 401); + + await api.routes.get('POST /plaza/v1/posts')( + createRequest({ + currentUser: { id: 'author-1' }, + body: { title: 'New' }, + }), + createResponseRecorder(), + ); + await api.routes.get('PATCH /plaza/v1/posts/:id')( + createRequest({ + currentUser: { id: 'author-1' }, + params: { id: 'post-1' }, + body: { title: 'Updated' }, + }), + createResponseRecorder(), + ); + await api.routes.get('DELETE /plaza/v1/posts/:id')( + createRequest({ + currentUser: { id: 'author-1' }, + params: { id: 'post-1' }, + }), + createResponseRecorder(), + ); + assert.deepEqual(calls, [ + { + kind: 'get', + args: ['post-1', { viewerId: 'viewer-1' }], + }, + { + kind: 'view', + args: ['post-1', '198.51.100.8'], + }, + { + kind: 'events', + input: { + userId: 'viewer-1', + sessionId: 'session-view', + events: [{ event_type: 'view', post_id: 'post-1' }], + }, + }, + { + kind: 'create', + args: ['author-1', { title: 'New' }], + }, + { + kind: 'update', + args: ['author-1', 'post-1', { title: 'Updated' }], + }, + { + kind: 'hide', + args: ['author-1', 'post-1'], + }, + ]); + assert.equal(deps.calls.data[1].status, 201); +}); + +test('service availability and route errors preserve existing error envelopes', async () => { + const unavailableApi = createRouterRecorder(); + const unavailable = createDependencies(); + attachPortalPlazaRoutes(unavailableApi, unavailable.dependencies); + const unavailableRes = createResponseRecorder(); + await unavailableApi.routes.get('GET /plaza/v1/feed')( + createRequest(), + unavailableRes, + ); + assert.equal(unavailableRes.statusCode, 503); + assert.equal(unavailable.calls.errors[0].code, 'plaza_unavailable'); + + const failureApi = createRouterRecorder(); + const error = new Error('feed failed'); + const failure = createDependencies({ + getPlazaPosts: () => ({ + async listFeed() { + throw error; + }, + }), + randomUUID: () => 'session-1', + }); + attachPortalPlazaRoutes(failureApi, failure.dependencies); + const failureReq = createRequest(); + const failureRes = createResponseRecorder(); + await failureApi.routes.get('GET /plaza/v1/feed')( + failureReq, + failureRes, + ); + assert.deepEqual(failure.calls.routeErrors[0], { + res: failureRes, + req: failureReq, + error, + }); +}); diff --git a/server/portal-publication-routes.mjs b/server/portal-publication-routes.mjs new file mode 100644 index 0000000..b3e1dee --- /dev/null +++ b/server/portal-publication-routes.mjs @@ -0,0 +1,538 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + buildMindSpacePublicRoutePath, + isPublicWorkspaceHtmlRelativePath, + resolveMindSpaceUserPublishDir, +} from '../mindspace-runtime-config.mjs'; +import { isPlazaEmbedRequest } from '../plaza-embed.mjs'; +import { + rasterizeThumbnailSvgToPng, +} from '../mindspace-thumbnail-png.mjs'; +import { PUBLIC_ZONE_DIR } from '../user-publish.mjs'; + +function escapePublicHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +export function renderPublicationPasswordGate( + action, +) { + return ` + + + + + + 受保护页面 + + + +
+

此页面受密码保护

+

请输入发布者提供的访问密码。密码不会写入链接或浏览器日志。

+ + +
+ +`; +} + +export function renderPublicHomepage(data) { + const cards = data.pages + .map( + (page) => `\ + +
+ ${escapePublicHtml(page.templateId)} + ${Number(page.viewCount)} 次浏览 +
+

${escapePublicHtml(page.title)}

+

${escapePublicHtml(page.summary || '暂无摘要')}

+
+ ${new Date(page.publishedAt).toLocaleDateString('zh-CN')} + 打开页面 +
+
`, + ) + .join(''); + return ` + + + + + + ${escapePublicHtml(data.owner.displayName)} · MindSpace + + + +
+
+

MindSpace Public

+

${escapePublicHtml(data.owner.displayName)}

+

这里展示 ${escapePublicHtml(data.owner.displayName)} 当前公开发布且在线的 MindSpace 页面。

+
+ ${Number(data.pageCount)} 个公开页面 + ${Number(data.totalViews)} 次累计浏览 + /u/${escapePublicHtml(data.owner.slug)} +
+
+ ${ + data.pages.length + ? `
${cards}
` + : '
这个主页还没有公开页面。稍后再来看看,或者直接访问作者分享给你的专属链接。
' + } +
+ +`; +} + +export function attachPortalPublicationRoutes({ + app, + urlencodedBody, + userAuthReady = Promise.resolve(), + h5Root, + getAuthPool = () => null, + getMindSpacePages = () => null, + getMindSpacePublications = () => null, + getUserAuth = () => null, + sendPublishedPage, + fsApi = fs, + pathApi = path, + publicZoneDir = PUBLIC_ZONE_DIR, + resolveUserPublishDir = + resolveMindSpaceUserPublishDir, + isEmbedRequest = isPlazaEmbedRequest, + isPublicWorkspaceHtml = + isPublicWorkspaceHtmlRelativePath, + buildPublicRoutePath = + buildMindSpacePublicRoutePath, + rasterizeThumbnail = + rasterizeThumbnailSvgToPng, +} = {}) { + if ( + !app || + !h5Root || + typeof urlencodedBody !== 'function' || + typeof sendPublishedPage !== 'function' + ) { + throw new Error( + 'attachPortalPublicationRoutes requires route dependencies', + ); + } + + async function resolvePublishedRoute( + req, + res, + password = null, + ) { + await userAuthReady; + const mindSpacePublications = + getMindSpacePublications(); + if (!mindSpacePublications) { + return res + .status(503) + .send('MindSpace 未启用'); + } + try { + const userAuth = getUserAuth(); + const viewer = + req.userSession && userAuth + ? await userAuth.getMe(req.userToken) + : null; + const result = + await mindSpacePublications.resolvePublic( + req.params.ownerSlug, + req.params.urlSlug, + viewer?.id, + password, + { + userAgent: req.get('user-agent'), + referrer: req.get('referer'), + }, + ); + const embed = isEmbedRequest(req.query); + const raw = + String(req.query.view ?? '') + .toLowerCase() === 'raw'; + if ( + !password && + !embed && + !raw && + result.publication?.accessMode === + 'public' && + isPublicWorkspaceHtml( + result.workspaceRelativePath, + ) + ) { + const redirectPath = + buildPublicRoutePath( + result.ownerId, + String( + result.workspaceRelativePath, + ).split('/'), + ); + return res.redirect(301, redirectPath); + } + return await sendPublishedPage( + req, + res, + result, + { + embed, + raw, + ownerSlug: req.params.ownerSlug, + }, + ); + } catch (error) { + if ( + error?.code === + 'publication_password_required' + ) { + return res + .status(password ? 403 : 200) + .send( + renderPublicationPasswordGate( + req.originalUrl, + ), + ); + } + if ( + error?.code === + 'publication_login_required' + ) { + return res + .status(401) + .send( + '请先登录 TKMind 后再访问此页面', + ); + } + if ( + error?.code === + 'publication_not_found' + ) { + return res + .status(404) + .send('页面不存在或已下线'); + } + return res + .status(500) + .send('页面加载失败'); + } + } + + app.use(async (req, res, next) => { + const thumbnailMatch = + /^\/u\/([^/]+)\/pages\/([^/]+)\.thumbnail\.png$/.exec( + req.path, + ); + if (!thumbnailMatch) return next(); + const ownerSlug = thumbnailMatch[1]; + const urlSlug = thumbnailMatch[2]; + await userAuthReady; + const authPool = getAuthPool(); + const mindSpacePages = + getMindSpacePages(); + if (!authPool || !mindSpacePages) { + return res + .status(503) + .send('MindSpace 未启用'); + } + try { + const [rows] = await authPool.query( + `SELECT pr.user_id, pr.page_id + FROM h5_publish_records pr + JOIN h5_users u ON u.id = pr.user_id + WHERE COALESCE(u.slug, u.username) = ? + AND pr.url_slug = ? + AND pr.status = 'online' + ORDER BY pr.published_at DESC + LIMIT 1`, + [ownerSlug, urlSlug], + ); + const row = rows[0]; + if (!row) { + return res + .status(404) + .send('缩略图不存在'); + } + const svg = + await mindSpacePages.renderThumbnail( + row.user_id, + row.page_id, + ); + res.set('Content-Type', 'image/png'); + res.set( + 'Cache-Control', + 'public, max-age=300', + ); + return res.send( + rasterizeThumbnail(svg), + ); + } catch { + return res + .status(500) + .send('缩略图加载失败'); + } + }); + + app.get( + '/u/:ownerSlug/pages/:urlSlug', + async (req, res) => + resolvePublishedRoute(req, res), + ); + + app.use( + '/u/:ownerSlug/public', + async (req, res) => { + await userAuthReady; + const ownerSlug = String( + req.params.ownerSlug ?? '', + ) + .trim() + .toLowerCase(); + if (!ownerSlug) { + return res + .status(404) + .json({ message: '用户不存在' }); + } + const authPool = getAuthPool(); + if (!authPool) { + return res + .status(503) + .send('MindSpace 未启用'); + } + + try { + const [rows] = await authPool.query( + `SELECT id FROM h5_users WHERE LOWER(COALESCE(slug, username)) = ? LIMIT 1`, + [ownerSlug], + ); + const userId = rows[0]?.id + ? String(rows[0].id) + : null; + if (!userId) { + return res + .status(404) + .json({ message: '用户不存在' }); + } + + const relativePath = String( + req.path ?? '', + ) + .replace(/^\/+/, '') + .split('/') + .map((segment) => + decodeURIComponent(segment), + ) + .filter(Boolean); + if (relativePath.length === 0) { + return res + .status(404) + .json({ message: '文件不存在' }); + } + + const publishDir = + resolveUserPublishDir(h5Root, { + id: userId, + }); + const resolvedRoot = + pathApi.resolve(publishDir); + const filePath = pathApi.resolve( + publishDir, + publicZoneDir, + ...relativePath, + ); + if ( + filePath !== resolvedRoot && + !filePath.startsWith( + `${resolvedRoot}${pathApi.sep}`, + ) + ) { + return res + .status(403) + .json({ message: '禁止访问' }); + } + if ( + !fsApi.existsSync(filePath) || + !fsApi.statSync(filePath).isFile() + ) { + return res + .status(404) + .json({ message: '文件不存在' }); + } + + return res.sendFile( + filePath, + (error) => { + if ( + error && + !res.headersSent + ) { + res + .status(404) + .json({ + message: '文件不存在', + }); + } + }, + ); + } catch { + return res + .status(500) + .send('文件加载失败'); + } + }, + ); + + app.get( + '/u/:ownerSlug', + async (req, res) => { + await userAuthReady; + const mindSpacePublications = + getMindSpacePublications(); + if (!mindSpacePublications) { + return res + .status(503) + .send('MindSpace 未启用'); + } + try { + const data = + await mindSpacePublications + .getPublicHomepage( + req.params.ownerSlug, + ); + res.set( + 'Content-Type', + 'text/html; charset=utf-8', + ); + res.set( + 'Content-Security-Policy', + "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", + ); + res.set( + 'Cache-Control', + 'public, max-age=60', + ); + return res.send( + renderPublicHomepage(data), + ); + } catch (error) { + if ( + error?.code === + 'publication_not_found' + ) { + return res + .status(404) + .send('主页不存在'); + } + return res + .status(500) + .send('主页加载失败'); + } + }, + ); + + app.post( + '/u/:ownerSlug/pages/:urlSlug', + urlencodedBody, + async (req, res) => + resolvePublishedRoute( + req, + res, + req.body?.password, + ), + ); + + app.get('/s/:token', async (req, res) => { + await userAuthReady; + const mindSpacePublications = + getMindSpacePublications(); + if (!mindSpacePublications) { + return res + .status(503) + .send('MindSpace 未启用'); + } + try { + const userAuth = getUserAuth(); + const viewer = + req.userSession && userAuth + ? await userAuth.getMe(req.userToken) + : null; + return await sendPublishedPage( + req, + res, + await mindSpacePublications + .resolvePrivateLink( + req.params.token, + viewer?.id, + { + userAgent: + req.get('user-agent'), + referrer: req.get('referer'), + }, + ), + { + embed: isEmbedRequest(req.query), + raw: + String(req.query.view ?? '') + .toLowerCase() === 'raw', + }, + ); + } catch (error) { + if ( + error?.code === + 'publication_not_found' + ) { + return res + .status(404) + .send('页面不存在或已下线'); + } + return res + .status(500) + .send('页面加载失败'); + } + }); +} diff --git a/server/portal-publication-routes.test.mjs b/server/portal-publication-routes.test.mjs new file mode 100644 index 0000000..b1b7485 --- /dev/null +++ b/server/portal-publication-routes.test.mjs @@ -0,0 +1,666 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + attachPortalPublicationRoutes, + renderPublicationPasswordGate, + renderPublicHomepage, +} from './portal-publication-routes.mjs'; + +function createResponse() { + return { + statusCode: 200, + body: undefined, + headers: {}, + headersSent: false, + redirectValue: null, + sentFile: null, + status(code) { + this.statusCode = code; + return this; + }, + set(name, value) { + this.headers[name] = value; + return this; + }, + send(body) { + this.body = body; + return this; + }, + json(body) { + this.body = body; + return this; + }, + redirect(status, location) { + this.statusCode = status; + this.redirectValue = location; + return this; + }, + sendFile(filePath, callback) { + this.sentFile = filePath; + callback?.(null); + return this; + }, + }; +} + +function createRequest(overrides = {}) { + return { + path: '/', + originalUrl: '/', + params: {}, + query: {}, + body: {}, + userSession: null, + userToken: null, + get(name) { + return { + 'user-agent': 'test-agent', + referer: 'https://referrer.example', + }[name]; + }, + ...overrides, + }; +} + +function createSetup(overrides = {}) { + const routes = []; + const calls = []; + const urlencodedBody = () => {}; + let authPool = { + async query(sql, params) { + calls.push(['query', sql, params]); + if (sql.includes('pr.user_id')) { + return [[{ + user_id: 'user-1', + page_id: 'page-1', + }]]; + } + return [[{ id: 'user-1' }]]; + }, + }; + let mindSpacePages = { + async renderThumbnail(userId, pageId) { + calls.push([ + 'thumbnail', + userId, + pageId, + ]); + return 'thumbnail'; + }, + }; + let publicResult = { + ownerId: 'user-1', + workspaceRelativePath: + 'public/page.html', + publication: { + accessMode: 'public', + }, + }; + let mindSpacePublications = { + async resolvePublic(...args) { + calls.push(['resolve-public', ...args]); + return publicResult; + }, + async getPublicHomepage(ownerSlug) { + calls.push(['homepage', ownerSlug]); + return { + owner: { + displayName: 'Alice', + slug: 'alice', + }, + pages: [], + pageCount: 0, + totalViews: 0, + }; + }, + async resolvePrivateLink(...args) { + calls.push(['resolve-private', ...args]); + return { + publication: { + accessMode: 'private_link', + }, + }; + }, + }; + let userAuth = { + async getMe(token) { + calls.push(['get-me', token]); + return { id: 'viewer-1' }; + }, + }; + const app = { + use(routePath, ...handlers) { + if (typeof routePath === 'function') { + routes.push({ + method: 'use', + path: null, + handlers: [ + routePath, + ...handlers, + ], + }); + return; + } + routes.push({ + method: 'use', + path: routePath, + handlers, + }); + }, + get(routePath, ...handlers) { + routes.push({ + method: 'get', + path: routePath, + handlers, + }); + }, + post(routePath, ...handlers) { + routes.push({ + method: 'post', + path: routePath, + handlers, + }); + }, + }; + const defaults = { + app, + urlencodedBody, + userAuthReady: Promise.resolve(), + h5Root: '/project', + getAuthPool: () => authPool, + getMindSpacePages: () => mindSpacePages, + getMindSpacePublications: () => + mindSpacePublications, + getUserAuth: () => userAuth, + async sendPublishedPage( + req, + res, + result, + options, + ) { + calls.push([ + 'send-published', + result, + options, + ]); + return res.send('published-page'); + }, + fsApi: { + existsSync() { + return true; + }, + statSync() { + return { + isFile: () => true, + }; + }, + }, + resolveUserPublishDir() { + return '/workspace/user-1'; + }, + isEmbedRequest(query) { + return query.embed === '1'; + }, + isPublicWorkspaceHtml(value) { + return value?.startsWith('public/'); + }, + buildPublicRoutePath(ownerId, segments) { + return `/MindSpace/${ownerId}/${segments.join('/')}`; + }, + rasterizeThumbnail(svg) { + calls.push(['rasterize', svg]); + return Buffer.from('png'); + }, + ...overrides, + }; + attachPortalPublicationRoutes(defaults); + + return { + routes, + calls, + urlencodedBody, + setAuthPool(value) { + authPool = value; + }, + setMindSpacePages(value) { + mindSpacePages = value; + }, + setMindSpacePublications(value) { + mindSpacePublications = value; + }, + setUserAuth(value) { + userAuth = value; + }, + setPublicResult(value) { + publicResult = value; + }, + route(method, routePath) { + return routes.find( + (route) => + route.method === method && + route.path === routePath, + ); + }, + }; +} + +async function invoke( + setup, + method, + routePath, + request = createRequest(), +) { + const route = setup.route(method, routePath); + assert.ok( + route, + `${method.toUpperCase()} ${routePath}`, + ); + const response = createResponse(); + let nextCalled = false; + await route.handlers.at(-1)( + request, + response, + () => { + nextCalled = true; + }, + ); + return { response, nextCalled }; +} + +test('registers publication route inventory and password middleware in order', () => { + const setup = createSetup(); + assert.deepEqual( + setup.routes.map(({ method, path }) => [ + method, + path, + ]), + [ + ['use', null], + [ + 'get', + '/u/:ownerSlug/pages/:urlSlug', + ], + ['use', '/u/:ownerSlug/public'], + ['get', '/u/:ownerSlug'], + [ + 'post', + '/u/:ownerSlug/pages/:urlSlug', + ], + ['get', '/s/:token'], + ], + ); + assert.equal( + setup.routes[4].handlers[0], + setup.urlencodedBody, + ); +}); + +test('public page redirects canonical workspace HTML and preserves viewer metadata', async () => { + const setup = createSetup(); + const { response } = await invoke( + setup, + 'get', + '/u/:ownerSlug/pages/:urlSlug', + createRequest({ + params: { + ownerSlug: 'alice', + urlSlug: 'page', + }, + userSession: { id: 'session' }, + userToken: 'viewer-token', + }), + ); + assert.equal(response.statusCode, 301); + assert.equal( + response.redirectValue, + '/MindSpace/user-1/public/page.html', + ); + assert.deepEqual( + setup.calls.find( + (call) => call[0] === 'resolve-public', + ), + [ + 'resolve-public', + 'alice', + 'page', + 'viewer-1', + null, + { + userAgent: 'test-agent', + referrer: + 'https://referrer.example', + }, + ], + ); +}); + +test('raw, embed, and password access preserve direct page delivery', async () => { + const setup = createSetup(); + const raw = await invoke( + setup, + 'get', + '/u/:ownerSlug/pages/:urlSlug', + createRequest({ + params: { + ownerSlug: 'alice', + urlSlug: 'page', + }, + query: { view: 'RAW' }, + }), + ); + assert.equal(raw.response.body, 'published-page'); + assert.deepEqual( + setup.calls.at(-1), + [ + 'send-published', + { + ownerId: 'user-1', + workspaceRelativePath: + 'public/page.html', + publication: { + accessMode: 'public', + }, + }, + { + embed: false, + raw: true, + ownerSlug: 'alice', + }, + ], + ); + + const posted = await invoke( + setup, + 'post', + '/u/:ownerSlug/pages/:urlSlug', + createRequest({ + params: { + ownerSlug: 'alice', + urlSlug: 'page', + }, + query: { embed: '1' }, + body: { + password: 'password-123', + }, + }), + ); + assert.equal( + posted.response.body, + 'published-page', + ); + const resolveCall = setup.calls + .filter( + (call) => + call[0] === 'resolve-public', + ) + .at(-1); + assert.equal(resolveCall[4], 'password-123'); +}); + +test('public page preserves password, login, not-found, and generic errors', async () => { + for (const [ + code, + password, + status, + text, + ] of [ + [ + 'publication_password_required', + null, + 200, + '此页面受密码保护', + ], + [ + 'publication_password_required', + 'wrong-password', + 403, + '此页面受密码保护', + ], + [ + 'publication_login_required', + null, + 401, + '请先登录 TKMind', + ], + [ + 'publication_not_found', + null, + 404, + '页面不存在或已下线', + ], + [ + 'unexpected', + null, + 500, + '页面加载失败', + ], + ]) { + const setup = createSetup(); + setup.setMindSpacePublications({ + async resolvePublic() { + throw Object.assign( + new Error(code), + { code }, + ); + }, + }); + const method = password ? 'post' : 'get'; + const result = await invoke( + setup, + method, + '/u/:ownerSlug/pages/:urlSlug', + createRequest({ + originalUrl: '/u/alice/pages/page', + params: { + ownerSlug: 'alice', + urlSlug: 'page', + }, + body: { password }, + }), + ); + assert.equal( + result.response.statusCode, + status, + ); + assert.match( + String(result.response.body), + new RegExp(text), + ); + } +}); + +test('thumbnail middleware preserves pass-through, lookup, headers, and errors', async () => { + const setup = createSetup(); + const middleware = setup.routes[0]; + const response = createResponse(); + let nextCalled = false; + await middleware.handlers[0]( + createRequest({ path: '/other' }), + response, + () => { + nextCalled = true; + }, + ); + assert.equal(nextCalled, true); + + const thumbnailResponse = createResponse(); + await middleware.handlers[0]( + createRequest({ + path: + '/u/alice/pages/page.thumbnail.png', + }), + thumbnailResponse, + () => {}, + ); + assert.equal( + thumbnailResponse.statusCode, + 200, + ); + assert.equal( + thumbnailResponse.headers['Content-Type'], + 'image/png', + ); + assert.equal( + thumbnailResponse.headers['Cache-Control'], + 'public, max-age=300', + ); + assert.equal( + thumbnailResponse.body.toString(), + 'png', + ); + + setup.setAuthPool(null); + const disabledResponse = createResponse(); + await middleware.handlers[0]( + createRequest({ + path: + '/u/alice/pages/page.thumbnail.png', + }), + disabledResponse, + () => {}, + ); + assert.equal( + disabledResponse.statusCode, + 503, + ); +}); + +test('public asset route preserves owner lookup, path, and file responses', async () => { + const setup = createSetup(); + const success = await invoke( + setup, + 'use', + '/u/:ownerSlug/public', + createRequest({ + path: '/assets/file.txt', + params: { ownerSlug: 'ALICE' }, + }), + ); + assert.equal( + success.response.sentFile, + '/workspace/user-1/public/assets/file.txt', + ); + const queryCall = setup.calls.find( + (call) => call[0] === 'query', + ); + assert.deepEqual(queryCall[2], ['alice']); + + setup.setAuthPool({ + async query() { + return [[]]; + }, + }); + const missing = await invoke( + setup, + 'use', + '/u/:ownerSlug/public', + createRequest({ + path: '/file.txt', + params: { ownerSlug: 'alice' }, + }), + ); + assert.equal(missing.response.statusCode, 404); + assert.deepEqual(missing.response.body, { + message: '用户不存在', + }); +}); + +test('homepage and private link preserve headers, viewer, and delivery options', async () => { + const setup = createSetup(); + const homepage = await invoke( + setup, + 'get', + '/u/:ownerSlug', + createRequest({ + params: { ownerSlug: 'alice' }, + }), + ); + assert.equal( + homepage.response.statusCode, + 200, + ); + assert.match( + homepage.response.body, + /Alice · MindSpace/, + ); + assert.equal( + homepage.response.headers['Cache-Control'], + 'public, max-age=60', + ); + + const privateLink = await invoke( + setup, + 'get', + '/s/:token', + createRequest({ + params: { token: 'private-token' }, + query: { + embed: '1', + view: 'raw', + }, + userSession: { id: 'session' }, + userToken: 'viewer-token', + }), + ); + assert.equal( + privateLink.response.body, + 'published-page', + ); + assert.deepEqual( + setup.calls.find( + (call) => + call[0] === 'resolve-private', + ), + [ + 'resolve-private', + 'private-token', + 'viewer-1', + { + userAgent: 'test-agent', + referrer: + 'https://referrer.example', + }, + ], + ); + assert.deepEqual( + setup.calls.at(-1)[2], + { + embed: true, + raw: true, + }, + ); +}); + +test('publication HTML helpers preserve escaping and password form action', () => { + const gate = + renderPublicationPasswordGate( + '/u/alice/pages/private', + ); + assert.match( + gate, + /action="\/u\/alice\/pages\/private"/, + ); + const homepage = renderPublicHomepage({ + owner: { + displayName: '', + slug: 'alice', + }, + pages: [{ + publicUrl: + 'https://example.test/?a=1&b=2', + templateId: '