Compare commits

..

12 Commits

42 changed files with 879 additions and 103 deletions
+4
View File
@@ -19,6 +19,7 @@ import { createLlmProviderService } from './llm-providers.mjs';
import { createAssetGatewayConfigService } from './asset-gateway.mjs';
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs';
import { createMindSearchConfigService } from './mindsearch-config.mjs';
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
import { createPlazaInteractionService } from './plaza-interactions.mjs';
@@ -101,6 +102,8 @@ export async function createAdminServices(env = {}) {
const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret });
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
const mindSearchConfigService = createMindSearchConfigService(pool);
await mindSearchConfigService.ensureSchema();
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root });
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
const adminSystemTestService = createAdminSystemTestService({
@@ -140,6 +143,7 @@ export async function createAdminServices(env = {}) {
llmProviderService,
assetGatewayConfigService,
memoryV2ConfigService,
mindSearchConfigService,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
+16
View File
@@ -45,6 +45,7 @@ export function createAdminApi({
llmProviderService,
assetGatewayConfigService,
memoryV2ConfigService,
mindSearchConfigService,
skillRuntimeConfigService,
adminSystemTestService,
plazaPosts,
@@ -142,6 +143,21 @@ export function createAdminApi({
adminApi.put('/memory-v2/config', requireAdmin, updateMemoryV2Config);
adminApi.patch('/memory-v2/config', requireAdmin, updateMemoryV2Config);
adminApi.get('/mindsearch/config', requireAdmin, async (_req, res) => {
if (!mindSearchConfigService?.getAdminConfig) return res.status(503).json({ message: 'MindSearch 配置服务未启用' });
return res.json(await mindSearchConfigService.getAdminConfig());
});
const updateMindSearchConfig = async (req, res) => {
if (!mindSearchConfigService?.updateAdminConfig) return res.status(503).json({ message: 'MindSearch 配置服务未启用' });
return res.json(await mindSearchConfigService.updateAdminConfig(req.body ?? {}, { updatedBy: req.currentUser.id }));
};
adminApi.put('/mindsearch/config', requireAdmin, updateMindSearchConfig);
adminApi.patch('/mindsearch/config', requireAdmin, updateMindSearchConfig);
adminApi.get('/mindsearch/runtime', requireAdmin, async (_req, res) => {
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置服务未启用' });
return res.json(await mindSearchConfigService.getRuntimeState());
});
adminApi.get('/memory-v2/runtime', requireAdmin, async (_req, res) => {
if (!memoryV2ConfigService?.getRuntimeState) {
return res.status(503).json({ message: 'Memory V2 配置服务未启用' });
+25
View File
@@ -90,6 +90,31 @@ test('admin memory-v2 config routes expose config and runtime state', async () =
}
});
test('admin MindSearch routes persist only through injected control-plane service', async () => {
const updates = [];
const router = createAdminApi({
jsonBody: express.json(), getToken() { return 'token-admin'; },
userAuth: { async getMe() { return { id: 'admin-1', role: 'admin' }; } },
llmProviderService: null, memoryV2ConfigService: null,
mindSearchConfigService: {
async getAdminConfig() { return { config: { enabled: false, mode: 'off', providers: { searxng: false, github: false, reader: false } }, source: 'env' }; },
async updateAdminConfig(patch, context) { updates.push({ patch, context }); return { config: { enabled: true, mode: 'shadow', providers: { searxng: true, github: false, reader: false } }, source: 'admin' }; },
async getRuntimeState() { return { effective: false, mode: 'off' }; },
},
plazaPosts: null, plazaOps: null, wechatAdmin: null, subscriptionService: null,
});
const server = await startTestServer(router);
try {
const config = await fetch(`${server.baseUrl}/admin-api/mindsearch/config`, { headers: { cookie: 'h5_user_session=token-admin' } });
assert.equal(config.status, 200);
const update = await fetch(`${server.baseUrl}/admin-api/mindsearch/config`, { method: 'PATCH', headers: { 'content-type': 'application/json', cookie: 'h5_user_session=token-admin' }, body: JSON.stringify({ enabled: true, mode: 'shadow' }) });
assert.equal(update.status, 200);
assert.deepEqual(updates, [{ patch: { enabled: true, mode: 'shadow' }, context: { updatedBy: 'admin-1' } }]);
const runtime = await fetch(`${server.baseUrl}/admin-api/mindsearch/runtime`, { headers: { cookie: 'h5_user_session=token-admin' } });
assert.deepEqual(await runtime.json(), { effective: false, mode: 'off' });
} finally { await server.close(); }
});
test('admin asset gateway routes preserve an explicit, admin-only control plane', async () => {
const calls = [];
const router = createAdminApi({
+1
View File
@@ -78,6 +78,7 @@ const CONSOLES = {
llmProviderService: services.llmProviderService,
assetGatewayConfigService: services.assetGatewayConfigService,
memoryV2ConfigService: services.memoryV2ConfigService,
mindSearchConfigService: services.mindSearchConfigService,
adminSystemTestService: services.adminSystemTestService,
plazaPosts: services.plazaPosts,
plazaOps: services.plazaOps,
+8 -1
View File
@@ -817,12 +817,19 @@ export function createAgentRunGateway({
let deliverables = null;
if (requiresPageDeliverable || typeof validateRunDeliverables === 'function') {
const latest = await getRunById(runId);
// `[]` is a strict allowlist meaning no workspace page may be examined.
// Static page runs commonly have no Page Data artifact list, so use
// `null` to discover the current run's newly synced workspace output.
const workspaceRelativePaths = Array.isArray(deliveryResult?.pageDataRelativePaths)
&& deliveryResult.pageDataRelativePaths.length > 0
? deliveryResult.pageDataRelativePaths
: null;
deliverables = await prepareAndDetectSessionDeliverables({
pool,
userId: row.user_id,
sessionId,
runStartedAtMs: latest?.started_at ?? row.started_at ?? null,
workspaceRelativePaths: deliveryResult?.pageDataRelativePaths ?? [],
workspaceRelativePaths,
});
if (requiresPageDeliverable && deliverables.pageCount < 1) {
const error = new Error(
+41
View File
@@ -619,6 +619,47 @@ test('agent run succeeds when Finish is missing but session pages were already c
);
});
test('static page run succeeds when no Page Data artifact path is recorded', async () => {
const pool = createFakePool({
workspaceDeliverables: {
'user-1': [{
page_id: 'page-static-workspace',
title: '都市时尚',
publication_id: 'pub-static-workspace',
publication_status: 'online',
public_url: 'http://127.0.0.1:5173/MindSpace/user-1/public/urban-fashion.html',
workspace_relative_path: 'public/urban-fashion.html',
updated_at: Date.now(),
}],
},
});
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {
async startSessionForUser() {
return { id: 'session-static-workspace' };
},
async submitSessionReplyAndAwaitFinishForUser() {
return { ok: true, finishEvent: { type: 'Finish' } };
},
},
// This mirrors a static-page-publish run: no Page Data artifact list.
syncUserPagesOnSuccess: async () => ({ pageDataBind: { errors: [] }, pageDataRelativePaths: [] }),
retryDelaysMs: [],
});
const run = await gateway.createRun('user-1', {
requestId: 'req-static-workspace-page',
userMessage: {
role: 'user',
content: [{ type: 'text', text: '生成一个都市时尚展示页面' }],
},
});
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
});
test('agent run uses direct chat service for eligible chat messages', async () => {
const pool = createFakePool();
const directRuns = [];
+19 -1
View File
@@ -15,6 +15,12 @@ export function resolveSandboxMcpNodeExecPath(overridePath) {
return process.execPath;
}
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 const CAPABILITY_CATALOG = [
{
key: 'shell',
@@ -130,6 +136,13 @@ export const CAPABILITY_CATALOG = [
risk: 'medium',
category: 'knowledge',
},
{
key: 'search_external',
label: 'MindSearch 外部搜索增强',
description: '可插拔的外部搜索补充能力;不替代现有网页搜索,默认关闭',
risk: 'medium',
category: 'knowledge',
},
{
key: 'computer',
label: '电脑控制',
@@ -190,6 +203,7 @@ export const DEFAULT_USER_CAPABILITIES = Object.fromEntries(
apps: false,
todo: false,
web: true,
search_external: false,
computer: false,
charts: false,
aider: false,
@@ -323,7 +337,7 @@ function sandboxMcpEnvs(sandboxMcp, mcpTools) {
*/
export function buildAgentExtensionPolicy(
capabilities,
{ unrestricted = false, policies = null, sandboxMcp = null, toolMode = 'chat' } = {},
{ unrestricted = false, policies = null, sandboxMcp = null, toolMode = 'chat', mindSearchConfig = null } = {},
) {
if (unrestricted) {
return { extensionOverrides: null, enableContextMemory: true, gooseMode: 'auto' };
@@ -416,6 +430,10 @@ export function buildAgentExtensionPolicy(
if (capabilities.web) {
extensions.push(makeExtension('platform', 'web', ['web_search', 'fetch_url']));
}
const searchEnabled = capabilities.search_external && mindSearchConfig?.enabled && mindSearchConfig.mode !== 'off';
if (searchEnabled && (!policies || policies.network_egress !== 'deny')) {
extensions.push({ type: 'stdio', name: 'tkmind-search', description: 'MindSearch 外部搜索增强(补充能力)', display_name: 'tkmind-search', bundled: false, cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH), args: [resolveMindSearchMcpServerPath(process.env.TKMIND_SEARCH_MCP_SERVER_PATH)], envs: { TKMIND_SEARCH_ENABLED: '1', TKMIND_SEARCH_MODE: mindSearchConfig.mode, TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED: mindSearchConfig.providers?.searxng ? '1' : '0', TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED: mindSearchConfig.providers?.github ? '1' : '0', TKMIND_SEARCH_READER_ENABLED: mindSearchConfig.providers?.reader ? '1' : '0', TKMIND_SEARCH_SEARXNG_URL: mindSearchConfig.settings?.searxngEndpoint ?? '', TKMIND_SEARCH_MAX_RESULTS: String(mindSearchConfig.settings?.maxResults ?? 10), TKMIND_SEARCH_TIMEOUT_MS: String(mindSearchConfig.settings?.timeoutMs ?? 8000), TKMIND_SEARCH_READER_MAX_CHARS: String(mindSearchConfig.settings?.readerMaxChars ?? 12000) }, available_tools: ['tkmind_search', 'tkmind_read'] });
}
if (capabilities.computer) {
extensions.push(makeExtension('builtin', 'computercontroller', []));
}
+12
View File
@@ -31,6 +31,18 @@ export function shouldPromoteSessionIdToStreaming(chatState) {
return chatState !== 'idle';
}
/**
* Only transport uncertainty may continue a run after submit fails. A
* deterministic gateway error already has a terminal outcome and must return
* the chat UI to an error/idle state instead of leaving a Stop button active.
*
* @param {number | undefined | null} status
* @returns {boolean}
*/
export function shouldKeepStreamingAfterRunError(status) {
return status === 0 || status === 409 || Number(status) >= 500;
}
/**
* Agent-run POST tracks portal request_id; Goose SSE may emit a different chat_request_id.
* Adopt the upstream id so Message/Finish events are not dropped in the UI.
+9
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
reconcileSessionEventRequestContext,
resolvePostAgentRunChatState,
shouldKeepStreamingAfterRunError,
shouldPromoteSessionIdToStreaming,
} from './chat-agent-run-gate.mjs';
@@ -47,6 +48,14 @@ test('completed run wins when the direct-chat snapshot is not ready', () => {
);
});
test('only ambiguous transport failures keep the chat streaming', () => {
assert.equal(shouldKeepStreamingAfterRunError(0), true);
assert.equal(shouldKeepStreamingAfterRunError(409), true);
assert.equal(shouldKeepStreamingAfterRunError(503), true);
assert.equal(shouldKeepStreamingAfterRunError(400), false);
assert.equal(shouldKeepStreamingAfterRunError(undefined), false);
});
test('shouldPromoteSessionIdToStreaming skips re-streaming after Finish', () => {
assert.equal(shouldPromoteSessionIdToStreaming('idle'), false);
assert.equal(shouldPromoteSessionIdToStreaming('waiting'), true);
+11
View File
@@ -101,6 +101,15 @@ export const CHAT_SKILL_DEFINITIONS = [
prefillOnly: true,
promptKey: 'search',
},
{
id: 'enhanced-search',
label: '外部增强搜索',
icon: 'web',
skillName: 'search-enhanced',
requiresSkill: 'search-enhanced',
prefillOnly: true,
promptKey: 'search-enhanced',
},
{
id: 'service-integration-smoke',
label: '联调测试',
@@ -175,6 +184,8 @@ export function buildChatSkillPrompt(promptKey, skillName) {
return `请使用 ${skillName ?? 'web'} 技能:帮我搜索并查阅相关资料(优先官方文档),并给出中文摘要与来源链接。我的问题是:`;
case 'search':
return `请使用 ${skillName ?? 'search'} 技能:帮我在工作区中查找代码或文件。我要找的是:`;
case 'search-enhanced':
return `请使用 ${skillName ?? 'search-enhanced'} 技能:这是可选的外部搜索增强能力。优先调用 tkmind-search 的 tkmind_search / tkmind_read;按 web/news/code/read 选择 Provider,返回标题、摘要、URL、来源和引用。若 MindSearch 不可用,自动回退到现有 web/search 能力,不要让搜索失败阻断回答。我的问题是:`;
case 'form-builder':
return `请使用 ${skillName ?? 'form-builder'} 技能:请用交互式表单收集以下场景所需的结构化字段(字段不超过 8 个):`;
case 'page-data-collect':
+6
View File
@@ -84,6 +84,12 @@ test('buildAutoChatSkillPrefix enables web for news-like queries', () => {
assert.equal(buildAutoChatSkillPrefix('帮我搜索今天热点新闻', ['search']), '');
});
test('manifest enhanced search route is opt-in and uses the MindSearch skill prompt', () => {
const routes = [{ skillName: 'search-enhanced', promptKey: 'search-enhanced', keywords: ['最新资料'], priority: 30 }];
assert.match(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', ['search-enhanced'], { skillRouterV2: true, manifestRoutes: routes }), /tkmind-search/);
assert.equal(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', [], { skillRouterV2: true, manifestRoutes: routes }), '');
});
test('buildAutoChatSkillPrefix enables publish for page generation requests', () => {
const prefix = buildAutoChatSkillPrefix('帮我做一个 Hello 糖的 H5 页面', ['static-page-publish']);
assert.match(prefix, /static-page-publish/);
+5
View File
@@ -0,0 +1,5 @@
# This digest selects the linux/arm64 manifest of darthsim/imgproxy:latest as
# verified on 2026-07-15. Do not replace it with a mutable tag in production.
FROM darthsim/imgproxy@sha256:3819ccd5ee26b83b27f8184b17a0a0c6f0eb21052efa9f554b23a7c39a3e6a1f
EXPOSE 8080
+18 -16
View File
@@ -20,25 +20,27 @@
可在发布面板或策略中逐项覆盖。
## Owner API(需登录)
## Owner API(需登录,仅后台管理界面
`/api/page-data/*` 已移除,不可被公开 HTML 调用;旧页面请求会收到 `410 legacy_page_data_api_removed`
```text
GET /api/page-data # 列出已注册 dataset
GET /api/page-data/:dataset
POST /api/page-data/:dataset/rows
PATCH /api/page-data/:dataset/rows/:id
DELETE /api/page-data/:dataset/rows/:id # soft delete
POST /api/page-data/:dataset/rows/:id/restore
GET /api/admin/page-data # 列出已注册 dataset
GET /api/admin/page-data/:dataset
POST /api/admin/page-data/:dataset/rows
PATCH /api/admin/page-data/:dataset/rows/:id
DELETE /api/admin/page-data/:dataset/rows/:id # soft delete
POST /api/admin/page-data/:dataset/rows/:id/restore
GET /api/page-data/policies
GET /api/page-data/policies/:pageId
PUT /api/page-data/policies/:pageId
POST /api/page-data/policies/:pageId/apply-publish
GET /api/page-data/policies/:pageId/ops
GET /api/page-data/policies/:pageId/logs
POST /api/page-data/policies/:pageId/tokens/revoke
POST /api/page-data/policies/:pageId/password/reset
POST /api/page-data/policies/:pageId/datasets/:dataset/close
GET /api/admin/page-data/policies
GET /api/admin/page-data/policies/:pageId
PUT /api/admin/page-data/policies/:pageId
POST /api/admin/page-data/policies/:pageId/apply-publish
GET /api/admin/page-data/policies/:pageId/ops
GET /api/admin/page-data/policies/:pageId/logs
POST /api/admin/page-data/policies/:pageId/tokens/revoke
POST /api/admin/page-data/policies/:pageId/password/reset
POST /api/admin/page-data/policies/:pageId/datasets/:dataset/close
```
## 公开 API(无需平台登录)
+99
View File
@@ -0,0 +1,99 @@
const CONFIG_TABLE = 'h5_mindsearch_config';
const CONFIG_SCOPE = 'global';
export const MINDSEARCH_DEFAULT_CONFIG = Object.freeze({
enabled: false,
mode: 'off',
providers: { searxng: false, github: false, reader: false },
settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 },
});
const clone = (value) => JSON.parse(JSON.stringify(value));
const bool = (value, fallback = false) => {
if (typeof value === 'boolean') return value;
if (value == null || value === '') return fallback;
return /^(1|true|yes|on)$/i.test(String(value));
};
export function normalizeMindSearchConfig(input = {}) {
const mode = ['off', 'shadow', 'assist'].includes(input.mode) ? input.mode : 'off';
const config = {
enabled: bool(input.enabled),
mode,
providers: {
searxng: bool(input.providers?.searxng),
github: bool(input.providers?.github),
reader: bool(input.providers?.reader),
},
settings: {
searxngEndpoint: String(input.settings?.searxngEndpoint ?? '').trim(),
maxResults: Math.max(1, Math.min(20, Number(input.settings?.maxResults ?? 10) || 10)),
timeoutMs: Math.max(1000, Math.min(30000, Number(input.settings?.timeoutMs ?? 8000) || 8000)),
readerMaxChars: Math.max(1000, Math.min(50000, Number(input.settings?.readerMaxChars ?? 12000) || 12000)),
},
};
if (!config.enabled || config.mode === 'off') {
config.enabled = false;
config.mode = 'off';
}
return config;
}
function envConfig(env = process.env) {
const enabled = bool(env.TKMIND_SEARCH_ENABLED);
return normalizeMindSearchConfig({
enabled,
mode: env.TKMIND_SEARCH_MODE || (enabled ? 'assist' : 'off'),
providers: {
searxng: env.TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED,
github: env.TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED,
reader: env.TKMIND_SEARCH_READER_ENABLED,
},
});
}
export async function ensureMindSearchConfigSchema(pool) {
await pool.query(`CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
config_scope VARCHAR(32) PRIMARY KEY,
config_json JSON NOT NULL,
updated_by CHAR(36) NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
}
export function createMindSearchConfigService(pool, { env = process.env } = {}) {
let ready = null;
const ensure = async () => {
if (!pool) return;
ready ??= ensureMindSearchConfigSchema(pool);
await ready;
};
const load = async () => {
const fallback = envConfig(env);
if (!pool) return { config: fallback, source: 'env' };
await ensure();
const [rows] = await pool.query(`SELECT config_json, updated_by, updated_at FROM ${CONFIG_TABLE} WHERE config_scope = ? LIMIT 1`, [CONFIG_SCOPE]);
if (!rows?.[0]) return { config: fallback, source: 'env' };
let stored = rows[0].config_json;
if (typeof stored === 'string') { try { stored = JSON.parse(stored); } catch { stored = {}; } }
return { config: normalizeMindSearchConfig(stored), source: 'admin', updatedBy: rows[0].updated_by ?? null, updatedAt: Number(rows[0].updated_at ?? 0) || null };
};
return {
ensureSchema: ensure,
getEffectiveConfig: async () => (await load()).config,
getAdminConfig: async () => load(),
updateAdminConfig: async (patch, { updatedBy = null } = {}) => {
const current = await load();
const next = normalizeMindSearchConfig({ ...current.config, ...patch, providers: { ...current.config.providers, ...(patch?.providers ?? {}) }, settings: { ...current.config.settings, ...(patch?.settings ?? {}) } });
if (!pool) return { config: next, source: 'env' };
await ensure();
const now = Date.now();
await pool.query(`INSERT INTO ${CONFIG_TABLE} (config_scope, config_json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE config_json = VALUES(config_json), updated_by = VALUES(updated_by), updated_at = VALUES(updated_at)`, [CONFIG_SCOPE, JSON.stringify(next), updatedBy, now]);
return { config: next, source: 'admin', updatedBy, updatedAt: now };
},
getRuntimeState: async () => {
const config = await (async () => (await load()).config)();
return { enabled: config.enabled, mode: config.mode, providers: config.providers, effective: config.enabled && config.mode !== 'off' };
},
};
}
+31
View File
@@ -0,0 +1,31 @@
import { isSafeHttpUrl, normalizeSearchResult } from './search-capability.mjs';
export async function searchSearxng(query, { limit = 10, endpoint = process.env.TKMIND_SEARCH_SEARXNG_URL, fetchImpl = fetch } = {}) {
if (!endpoint) throw new Error('SearXNG endpoint is not configured');
try { if (!['http:', 'https:'].includes(new URL(endpoint).protocol)) throw new Error('unsupported protocol'); } catch { throw new Error('SearXNG endpoint is not configured safely'); }
const url = new URL(endpoint); url.searchParams.set('q', query); url.searchParams.set('format', 'json');
const response = await fetchImpl(url, { signal: AbortSignal.timeout(Number(process.env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000)), headers: { accept: 'application/json' } });
if (!response.ok) throw new Error(`SearXNG returned ${response.status}`);
const body = await response.json();
return (Array.isArray(body.results) ? body.results : []).slice(0, limit).map((item, index) => normalizeSearchResult({ ...item, source: 'searxng' }, index));
}
export async function readSafeUrl(target, { fetchImpl = fetch } = {}) {
if (!isSafeHttpUrl(target)) throw new Error('unsafe URL');
const response = await fetchImpl(target, { signal: AbortSignal.timeout(8000), headers: { accept: 'text/html,text/plain' } });
if (!response.ok) throw new Error(`reader returned ${response.status}`);
return { url: target, content: (await response.text()).slice(0, Number(process.env.TKMIND_SEARCH_READER_MAX_CHARS ?? 12000)) };
}
export async function searchGithubCode(query, { limit = 10, token = process.env.GITHUB_TOKEN, endpoint = process.env.TKMIND_SEARCH_GITHUB_URL || 'https://api.github.com/search/code', fetchImpl = fetch } = {}) {
const url = new URL(endpoint);
if (url.protocol !== 'https:' || url.hostname !== 'api.github.com') throw new Error('GitHub endpoint is not configured safely');
url.searchParams.set('q', query);
url.searchParams.set('per_page', String(Math.min(20, limit)));
const headers = { accept: 'application/vnd.github+json', 'user-agent': 'tkmind-search' };
if (token) headers.authorization = `Bearer ${token}`;
const response = await fetchImpl(url, { headers, signal: AbortSignal.timeout(8000) });
if (!response.ok) throw new Error(`GitHub returned ${response.status}`);
const body = await response.json();
return (Array.isArray(body.items) ? body.items : []).slice(0, limit).map((item, index) => normalizeSearchResult({ title: `${item.repository?.full_name ?? ''}:${item.path ?? item.name ?? ''}`, url: item.html_url, snippet: item.repository?.description ?? '', source: 'github' }, index));
}
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildCitations, isSafeHttpUrl, validateSearchRequest } from './search-capability.mjs';
import { MINDSEARCH_DEFAULT_CONFIG, normalizeMindSearchConfig } from './mindsearch-config.mjs';
import { buildAgentExtensionPolicy, DEFAULT_USER_CAPABILITIES } from './capabilities.mjs';
import { searchGithubCode, searchSearxng } from './mindsearch-providers.mjs';
test('MindSearch defaults to disabled and normalizes unsafe modes', () => {
assert.equal(MINDSEARCH_DEFAULT_CONFIG.enabled, false);
assert.deepEqual(normalizeMindSearchConfig({ enabled: true, mode: 'invalid', providers: { github: true } }), { enabled: false, mode: 'off', providers: { searxng: false, github: true, reader: false }, settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 } });
});
test('search request validation and citations are deterministic', () => {
assert.equal(validateSearchRequest({ query: '' }).code, 'INVALID_REQUEST');
const checked = validateSearchRequest({ query: ' Goose ', limit: 999 });
assert.deepEqual(checked.value, { query: 'Goose', type: 'web', limit: 20 });
assert.deepEqual(buildCitations([{ title: 'A', url: 'https://example.com', source: 'test' }]), [{ id: '[1]', title: 'A', url: 'https://example.com', source: 'test' }]);
assert.equal(isSafeHttpUrl('http://127.0.0.1:8081/private'), false);
assert.equal(isSafeHttpUrl('https://example.com/a'), true);
});
test('MindSearch never changes legacy web extension and is gated by capability/config', () => {
const base = { ...DEFAULT_USER_CAPABILITIES, web: true, search_external: false };
let policy = buildAgentExtensionPolicy(base, { mindSearchConfig: { enabled: true, mode: 'assist', providers: {} } });
assert.ok(policy.extensionOverrides.some((ext) => ext.name === 'web'));
assert.equal(policy.extensionOverrides.some((ext) => ext.name === 'tkmind-search'), false);
policy = buildAgentExtensionPolicy({ ...base, search_external: true }, { mindSearchConfig: { enabled: true, mode: 'assist', providers: {} }, policies: { network_egress: 'deny' } });
assert.equal(policy.extensionOverrides.some((ext) => ext.name === 'tkmind-search'), false);
policy = buildAgentExtensionPolicy({ ...base, search_external: true }, { mindSearchConfig: { enabled: true, mode: 'assist', providers: {} } });
assert.ok(policy.extensionOverrides.some((ext) => ext.name === 'tkmind-search'));
});
test('SearXNG adapter normalizes provider results without requiring a live network', async () => {
const result = await searchSearxng('goose', { endpoint: 'http://search.local', fetchImpl: async () => ({ ok: true, json: async () => ({ results: [{ title: 'Goose', url: 'https://example.com', content: 'snippet' }] }) }) });
assert.deepEqual(result[0], { title: 'Goose', url: 'https://example.com', snippet: 'snippet', source: 'searxng', rank: 1 });
});
test('GitHub code adapter sends bounded queries and normalizes results', async () => {
let requested;
const result = await searchGithubCode('repo:openai goose', { limit: 50, fetchImpl: async (url, options) => { requested = { url: String(url), options }; return { ok: true, json: async () => ({ items: [{ repository: { full_name: 'openai/goose', description: 'agent' }, path: 'src/goose.rs', html_url: 'https://github.com/openai/goose/blob/main/src/goose.rs' }] }) }; } });
assert.match(requested.url, /per_page=20/);
assert.equal(requested.options.headers.accept, 'application/vnd.github+json');
assert.equal(result[0].source, 'github');
});
@@ -96,6 +96,13 @@ test('inferPageDataBindAccessMode chooses password for admin html', () => {
'password',
);
assert.equal(inferPageDataBindAccessMode('public/diet-survey.html', SURVEY_HTML), 'public');
assert.equal(
inferPageDataBindAccessMode(
'public/private-tracker.html',
'<script>const client = MindSpacePageData.createClient(); client.authenticate(password); client.listRows(\'entries\'); client.insertRow(\'entries\', {});</script>',
),
'password',
);
});
test('evaluatePageDataFinishGuard detects unbound page data html', () => {
+1
View File
@@ -36,6 +36,7 @@
"enrich:plaza": "node scripts/enrich-plaza-posts.mjs",
"build": "vite build",
"build:portal-runtime": "node scripts/build-portal-runtime.mjs",
"build:imgproxy-runtime": "bash scripts/build-imgproxy-runtime-image.sh",
"build:mindspace-service-runtime": "node scripts/build-mindspace-service-runtime.mjs",
"check:mindspace-public-links": "node scripts/check-mindspace-public-links.mjs --downloads-only",
"check:mindspace-public-links:all": "node scripts/check-mindspace-public-links.mjs --all-links",
+3 -1
View File
@@ -24,7 +24,9 @@ export function normalizePolicyDataset(name, raw) {
insert: Boolean(raw.insert),
update: Boolean(raw.update),
softDelete: Boolean(raw.softDelete ?? raw.soft_delete),
hardDelete: Boolean(raw.hardDelete ?? raw.hard_delete),
// `delete` is the page-facing spelling for an intentional permanent PG
// delete; retain hardDelete for existing policies.
hardDelete: Boolean(raw.hardDelete ?? raw.hard_delete ?? raw.delete),
columns: normalizedColumns,
limits:
raw.limits && typeof raw.limits === 'object'
+4 -4
View File
@@ -121,19 +121,19 @@ test('acceptance: agent dataset registry + owner read/insert via Page Data API',
await setupWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
const datasets = await request(app, 'GET', '/api/page-data', {
const datasets = await request(app, 'GET', '/api/admin/page-data', {
headers: { 'x-test-user': '1' },
});
assert.equal(datasets.status, 200);
assert.equal(datasets.body.data.datasets[0].name, 'leads');
const inserted = await request(app, 'POST', '/api/page-data/leads/rows', {
const inserted = await request(app, 'POST', '/api/admin/page-data/leads/rows', {
headers: { 'x-test-user': '1' },
body: { name: 'Owner 线索', note: '内部' },
});
assert.equal(inserted.status, 201);
const listed = await request(app, 'GET', '/api/page-data/leads?limit=10', {
const listed = await request(app, 'GET', '/api/admin/page-data/leads?limit=10', {
headers: { 'x-test-user': '1' },
});
assert.equal(listed.status, 200);
@@ -237,7 +237,7 @@ test('acceptance: apply-publish creates policy for published page', async () =>
await setupWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
const applied = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/apply-publish`, {
const applied = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/apply-publish`, {
headers: { 'x-test-user': '1' },
body: {
datasetName: 'leads',
+23 -1
View File
@@ -41,12 +41,21 @@ export function detectPageDataDatasetUsageFromHtml(html) {
for (const match of text.matchAll(/\.listRows\(\s*['"]([^'"]+)['"]/g)) {
remember(match[1], { read: true });
}
for (const match of text.matchAll(/\.deleteRow\(\s*['"]([^'"]+)['"]/g)) {
// The browser client's deleteRow endpoint intentionally falls back to a
// soft delete unless a policy explicitly grants hard_delete. A page using
// this API must therefore only require the safe, default capability.
remember(match[1], { softDelete: true });
}
for (const match of text.matchAll(/\.insertRow\(\s*([A-Za-z_$][\w$]*)/g)) {
remember(constants.get(match[1]) ?? match[1], { insert: true });
}
for (const match of text.matchAll(/\.listRows\(\s*([A-Za-z_$][\w$]*)/g)) {
remember(constants.get(match[1]) ?? match[1], { read: true });
}
for (const match of text.matchAll(/\.deleteRow\(\s*([A-Za-z_$][\w$]*)/g)) {
remember(constants.get(match[1]) ?? match[1], { softDelete: true });
}
return datasets;
}
@@ -55,7 +64,16 @@ export function inferPageDataBindAccessMode(relativePath, html) {
const usage = detectPageDataDatasetUsageFromHtml(html);
const hasRead = [...usage.values()].some((item) => item.read);
const hasInsert = [...usage.values()].some((item) => item.insert);
if (/-admin\.html$/i.test(String(relativePath ?? '')) || (hasRead && !hasInsert)) {
// A page that exchanges a password for a Page Data token is intentionally
// protected even when it both reads and writes its dataset (for example, a
// personal tracker). Do not silently re-bind it as public merely because it
// is not named "-admin.html".
const usesServerAuthentication = /\.\s*authenticate\s*\(/.test(String(html ?? ''));
if (
/-admin\.html$/i.test(String(relativePath ?? '')) ||
(hasRead && !hasInsert) ||
usesServerAuthentication
) {
return 'password';
}
return 'public';
@@ -122,6 +140,10 @@ export function buildPageDataPolicyDatasetsFromRegistry({ html, registryDatasets
entry.columns.read = registered.columns?.read ?? [];
}
}
if (perms.softDelete) {
entry.softDelete = true;
entry.columns.soft_delete = registered.columns?.soft_delete ?? ['id'];
}
datasets[name] = entry;
}
+12 -2
View File
@@ -15,14 +15,24 @@ test('htmlUsesForbiddenLegacyPageDataApi detects /api/page-data passthrough', ()
assert.equal(detectPageDataDatasetUsageFromHtml(html).size, 0);
});
test('detectPageDataDatasetUsageFromHtml finds insert and read datasets', () => {
test('detectPageDataDatasetUsageFromHtml treats browser deleteRow as a soft delete', () => {
const html = `
await c.insertRow('tkmind_exp_survey', { satisfaction: '满意' });
await c.listRows('tkmind_exp_survey', { limit: 10 });
await c.deleteRow('tkmind_exp_survey', 42);
`;
const usage = detectPageDataDatasetUsageFromHtml(html);
assert.equal(usage.size, 1);
assert.deepEqual(usage.get('tkmind_exp_survey'), { insert: true, read: true });
assert.deepEqual(usage.get('tkmind_exp_survey'), { insert: true, read: true, softDelete: true });
});
test('buildPageDataPolicyDatasetsFromRegistry grants registered soft delete when HTML uses deleteRow', () => {
const datasets = buildPageDataPolicyDatasetsFromRegistry({
html: `const DATASET = 'mood_notes'; await c.deleteRow(DATASET, 1);`,
registryDatasets: [{ name: 'mood_notes', columns: { soft_delete: ['id'] } }],
});
assert.equal(datasets.mood_notes.softDelete, true);
assert.deepEqual(datasets.mood_notes.columns.soft_delete, ['id']);
});
test('detectPageDataDatasetUsageFromHtml resolves dataset constants', () => {
+11 -11
View File
@@ -133,14 +133,14 @@ test('integration: owner private API and public insert coexist without breaking
const service = await setupWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
const ownerInsert = await request(app, 'POST', '/api/page-data/entries/rows', {
const ownerInsert = await request(app, 'POST', '/api/admin/page-data/entries/rows', {
headers: { 'x-test-user': '1' },
body: { title: 'owner 写入' },
});
assert.equal(ownerInsert.status, 201);
assert.equal(ownerInsert.body.data.row.title, 'owner 写入');
const ownerList = await request(app, 'GET', '/api/page-data/entries?limit=10', {
const ownerList = await request(app, 'GET', '/api/admin/page-data/entries?limit=10', {
headers: { 'x-test-user': '1' },
});
assert.equal(ownerList.status, 200);
@@ -157,7 +157,7 @@ test('integration: owner private API and public insert coexist without breaking
},
},
};
const policyWrite = await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, {
const policyWrite = await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, {
headers: { 'x-test-user': '1' },
body: policy,
});
@@ -174,7 +174,7 @@ test('integration: owner private API and public insert coexist without breaking
assert.equal(publicReadDenied.status, 403);
assert.equal(publicReadDenied.body.error.code, 'action_not_allowed');
const ownerListAfterPublic = await request(app, 'GET', '/api/page-data/entries?limit=10', {
const ownerListAfterPublic = await request(app, 'GET', '/api/admin/page-data/entries?limit=10', {
headers: { 'x-test-user': '1' },
});
assert.equal(ownerListAfterPublic.status, 200);
@@ -188,7 +188,7 @@ test('integration: public insert rejects SQL injection style payload keys', asyn
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-integration-sql-'));
await setupWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, {
await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, {
headers: { 'x-test-user': '1' },
body: {
pageId: PAGE_ID,
@@ -252,7 +252,7 @@ test('integration: password publication flow still works for read after data-aut
const app = express();
app.use('/api', api);
await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, {
await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, {
headers: { 'x-test-user': '1' },
body: {
pageId: PAGE_ID,
@@ -284,7 +284,7 @@ test('integration: public insert writes operation logs for owner review', async
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-logs-int-'));
await setupPublicWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, {
await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, {
headers: { 'x-test-user': '1' },
body: {
pageId: PAGE_ID,
@@ -296,7 +296,7 @@ test('integration: public insert writes operation logs for owner review', async
await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, {
body: { name: '日志测试', phone: '13800000000' },
});
const logs = await request(app, 'GET', `/api/page-data/policies/${PAGE_ID}/logs`, {
const logs = await request(app, 'GET', `/api/admin/page-data/policies/${PAGE_ID}/logs`, {
headers: { 'x-test-user': '1' },
});
assert.equal(logs.status, 200);
@@ -313,7 +313,7 @@ test('integration: owner can export dataset and restore soft-deleted rows', asyn
phone: '13800000001',
});
const app = buildApp(workspaceRoot);
const exported = await request(app, 'GET', '/api/page-data/signups/export?format=json', {
const exported = await request(app, 'GET', '/api/admin/page-data/signups/export?format=json', {
headers: { 'x-test-user': '1' },
});
assert.equal(exported.status, 200);
@@ -322,7 +322,7 @@ test('integration: owner can export dataset and restore soft-deleted rows', asyn
await ownerService.softDeleteRowForDataset(ownerService.getDataset('signups'), inserted.row.id, {
deletedBy: 'test',
});
const restored = await request(app, 'POST', `/api/page-data/signups/rows/${inserted.row.id}/restore`, {
const restored = await request(app, 'POST', `/api/admin/page-data/signups/rows/${inserted.row.id}/restore`, {
headers: { 'x-test-user': '1' },
});
assert.equal(restored.status, 200);
@@ -334,7 +334,7 @@ test('integration: apply-publish route binds dataset after publication', async (
await setupPublicWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
const applied = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/apply-publish`, {
const applied = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/apply-publish`, {
headers: { 'x-test-user': '1' },
body: {
datasetName: 'signups',
+4 -4
View File
@@ -142,7 +142,7 @@ test('ops overview returns datasets stats logs and active sessions', async () =>
headers: { 'x-page-data-token': (await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, { body: { password: OLD_PASSWORD } })).body.data.token },
});
const overview = await request(app, 'GET', `/api/page-data/policies/${PAGE_ID}/ops`, {
const overview = await request(app, 'GET', `/api/admin/page-data/policies/${PAGE_ID}/ops`, {
headers: { 'x-test-user': '1' },
});
assert.equal(overview.status, 200);
@@ -171,7 +171,7 @@ test('owner can revoke all tokens and reset password', async () => {
let updatedPasswordHash = null;
const app = buildApp(workspaceRoot, createPool({ onUpdate: ([hash]) => { updatedPasswordHash = hash; } }), sessionStore);
const revoked = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/tokens/revoke`, {
const revoked = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/tokens/revoke`, {
headers: { 'x-test-user': '1' },
body: { revokeAll: true },
});
@@ -180,7 +180,7 @@ test('owner can revoke all tokens and reset password', async () => {
assert.equal(sessionStore.verify(auth1.token), null);
assert.equal(sessionStore.verify(auth2.token), null);
const reset = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/password/reset`, {
const reset = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/password/reset`, {
headers: { 'x-test-user': '1' },
body: { password: NEW_PASSWORD },
});
@@ -210,7 +210,7 @@ test('owner can close dataset and public insert is denied afterwards', async ()
body: { password: OLD_PASSWORD },
});
const closed = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/datasets/signups/close`, {
const closed = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/datasets/signups/close`, {
headers: { 'x-test-user': '1' },
});
assert.equal(closed.status, 200);
+12 -6
View File
@@ -501,18 +501,21 @@ export function createPageDataPublicService(deps = {}) {
});
}
async function softDeleteRow(pageId, datasetName, rowId, req) {
async function deleteRow(pageId, datasetName, rowId, req) {
return runPublicAction(async () => {
const publication = await queryPublication(pageId);
const policy = await loadPolicy(publication);
const deleteAction = policyAllowsAction(policy, datasetName, 'hard_delete')
? 'hard_delete'
: 'soft_delete';
const access = resolveAccessContext({
publication,
policy,
req,
action: 'soft_delete',
action: deleteAction,
datasetName,
});
enforceRateLimit(req, publication, 'soft_delete');
enforceRateLimit(req, publication, deleteAction);
const { ownerService, effective, policyDataset } = await resolveEffectiveDataset(
publication.user_id,
policy,
@@ -531,13 +534,15 @@ export function createPageDataPublicService(deps = {}) {
}
: null,
);
const result = await ownerService.softDeleteRowForDataset(effective, rowId, {
const result = deleteAction === 'hard_delete'
? await ownerService.hardDeleteRowForDataset(effective, rowId, { rowScope })
: await ownerService.softDeleteRowForDataset(effective, rowId, {
deletedBy: access.session?.sessionId ?? 'public',
rowScope,
});
recordPublicLog(resolveWorkspaceRoot(publication.user_id), publication, {
datasetName,
action: 'soft_delete',
action: deleteAction,
rowId,
req,
session: access.session,
@@ -781,7 +786,8 @@ export function createPageDataPublicService(deps = {}) {
getStats,
insertRow,
updateRow,
softDeleteRow,
deleteRow,
softDeleteRow: deleteRow,
getOwnerPolicy,
saveOwnerPolicy,
saveOwnerPolicyFromPublish,
+42 -19
View File
@@ -1,6 +1,16 @@
import { stripPageDataMetaFields } from './page-data-captcha.mjs';
import { mapPageDataPublicError } from './page-data-public-service.mjs';
// Owner/admin APIs intentionally use a separate namespace from APIs callable
// by published HTML. A generated public page must only use
// `/api/public/pages/:pageId/data/...`; keeping `/api/page-data` live made it
// too easy for an LLM-generated page to accidentally target owner APIs.
const OWNER_PAGE_DATA_API_PREFIX = '/admin/page-data';
export function isLegacyPageDataApiPath(pathname) {
return /^\/page-data(?:\/|$)/.test(String(pathname ?? ''));
}
function requireUser(req, res, sendError) {
if (!req.currentUser?.id) {
sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
@@ -31,7 +41,7 @@ function handlePublicError(res, req, sendError, error) {
export function attachPageDataRoutes(api, deps) {
const { sendData, sendError, getPageDataService, getPageDataPublicService } = deps;
api.get('/page-data', async (req, res) => {
api.get(OWNER_PAGE_DATA_API_PREFIX, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const service = getPageDataService?.();
@@ -45,7 +55,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.get('/page-data/policies', async (req, res) => {
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/policies`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const publicService = getPageDataPublicService?.();
@@ -58,7 +68,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.get('/page-data/policies/:pageId', async (req, res) => {
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const publicService = getPageDataPublicService?.();
@@ -72,7 +82,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.post('/page-data/policies/:pageId/apply-publish', async (req, res) => {
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/apply-publish`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const publicService = getPageDataPublicService?.();
@@ -90,7 +100,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.put('/page-data/policies/:pageId', async (req, res) => {
api.put(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const publicService = getPageDataPublicService?.();
@@ -103,7 +113,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.get('/page-data/policies/:pageId/logs', async (req, res) => {
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/logs`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const service = getPageDataService?.();
@@ -120,7 +130,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.get('/page-data/policies/:pageId/ops', async (req, res) => {
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/ops`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const publicService = getPageDataPublicService?.();
@@ -133,7 +143,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.post('/page-data/policies/:pageId/tokens/revoke', async (req, res) => {
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/tokens/revoke`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const publicService = getPageDataPublicService?.();
@@ -149,7 +159,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.post('/page-data/policies/:pageId/password/reset', async (req, res) => {
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/password/reset`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const publicService = getPageDataPublicService?.();
@@ -164,7 +174,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.post('/page-data/policies/:pageId/datasets/:dataset/close', async (req, res) => {
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/datasets/:dataset/close`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const publicService = getPageDataPublicService?.();
@@ -177,7 +187,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.get('/page-data/:dataset/export', async (req, res) => {
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/export`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const service = getPageDataService?.();
@@ -203,7 +213,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.get('/page-data/:dataset', async (req, res) => {
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const service = getPageDataService?.();
@@ -228,7 +238,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.get('/page-data/:dataset/schema', async (req, res) => {
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/schema`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const service = getPageDataService?.();
@@ -242,7 +252,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.get('/page-data/:dataset/stats', async (req, res) => {
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/stats`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const service = getPageDataService?.();
@@ -256,7 +266,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.post('/page-data/:dataset/rows', async (req, res) => {
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const service = getPageDataService?.();
@@ -272,7 +282,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.post('/page-data/:dataset/rows/:rowId/restore', async (req, res) => {
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows/:rowId/restore`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const service = getPageDataService?.();
@@ -286,7 +296,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.patch('/page-data/:dataset/rows/:rowId', async (req, res) => {
api.patch(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows/:rowId`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const service = getPageDataService?.();
@@ -302,7 +312,7 @@ export function attachPageDataRoutes(api, deps) {
}
});
api.delete('/page-data/:dataset/rows/:rowId', async (req, res) => {
api.delete(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows/:rowId`, async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const service = getPageDataService?.();
@@ -408,7 +418,7 @@ export function attachPageDataRoutes(api, deps) {
const publicService = getPageDataPublicService?.();
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
try {
const result = await publicService.softDeleteRow(
const result = await publicService.deleteRow(
req.params.pageId,
req.params.dataset,
req.params.rowId,
@@ -419,4 +429,17 @@ export function attachPageDataRoutes(api, deps) {
return handlePublicError(res, req, sendError, error);
}
});
// Do not retain a compatibility alias. It would keep the forbidden API
// usable from generated pages and turn a future prompt regression into a
// production data-access bug. Owner UI has moved to /admin/page-data.
api.use('/page-data', (req, res) =>
sendError(
res,
req,
410,
'legacy_page_data_api_removed',
'旧版 Page Data API 已移除;公开页面请使用 /api/public/pages/:pageId/data,后台请使用 /api/admin/page-data。',
),
);
}
+25 -11
View File
@@ -4,7 +4,7 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import express from 'express';
import { attachPageDataRoutes } from './page-data-routes.mjs';
import { attachPageDataRoutes, isLegacyPageDataApiPath } from './page-data-routes.mjs';
import { createPageDataService } from './page-data-service.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
@@ -82,21 +82,21 @@ test('page data routes allow logged-in owner to read and insert dataset rows', a
user: { id: 'user-1', workspaceRoot },
});
const inserted = await requestJson(app, 'POST', '/api/page-data/feedback/rows', {
const inserted = await requestJson(app, 'POST', '/api/admin/page-data/feedback/rows', {
message: '很好用',
});
assert.equal(inserted.status, 201);
assert.equal(inserted.body.data.row.message, '很好用');
const listed = await requestJson(app, 'GET', '/api/page-data/feedback?limit=10');
const listed = await requestJson(app, 'GET', '/api/admin/page-data/feedback?limit=10');
assert.equal(listed.status, 200);
assert.equal(listed.body.data.rows.length, 1);
const schema = await requestJson(app, 'GET', '/api/page-data/feedback/schema');
const schema = await requestJson(app, 'GET', '/api/admin/page-data/feedback/schema');
assert.equal(schema.status, 200);
assert.equal(schema.body.data.dataset.name, 'feedback');
const stats = await requestJson(app, 'GET', '/api/page-data/feedback/stats');
const stats = await requestJson(app, 'GET', '/api/admin/page-data/feedback/stats');
assert.equal(stats.status, 200);
assert.equal(stats.body.data.total, 1);
});
@@ -130,21 +130,21 @@ test('page data routes allow owner update and soft delete', async () => {
user: { id: 'user-1', workspaceRoot },
});
const inserted = await requestJson(app, 'POST', '/api/page-data/tasks/rows', { title: '待办' });
const inserted = await requestJson(app, 'POST', '/api/admin/page-data/tasks/rows', { title: '待办' });
assert.equal(inserted.status, 201);
const rowId = inserted.body.data.row.id;
const updated = await requestJson(app, 'PATCH', `/api/page-data/tasks/rows/${rowId}`, {
const updated = await requestJson(app, 'PATCH', `/api/admin/page-data/tasks/rows/${rowId}`, {
status: 'done',
});
assert.equal(updated.status, 200);
assert.equal(updated.body.data.row.status, 'done');
const deleted = await requestJson(app, 'DELETE', `/api/page-data/tasks/rows/${rowId}`);
const deleted = await requestJson(app, 'DELETE', `/api/admin/page-data/tasks/rows/${rowId}`);
assert.equal(deleted.status, 200);
assert.equal(deleted.body.data.deleted, true);
const listed = await requestJson(app, 'GET', '/api/page-data/tasks?limit=10');
const listed = await requestJson(app, 'GET', '/api/admin/page-data/tasks?limit=10');
assert.equal(listed.status, 200);
assert.equal(listed.body.data.rows.length, 0);
});
@@ -168,11 +168,25 @@ test('page data routes reject unauthorized dataset action', async () => {
user: { id: 'user-1', workspaceRoot },
});
const denied = await requestJson(app, 'GET', '/api/page-data/hidden');
const denied = await requestJson(app, 'GET', '/api/admin/page-data/hidden');
assert.equal(denied.status, 403);
assert.equal(denied.body.error.code, 'action_not_allowed');
});
test('legacy page data API is permanently unavailable', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-legacy-'));
const app = createApiApp({ workspaceRoot, user: { id: 'user-1', workspaceRoot } });
const blocked = await requestJson(app, 'POST', '/api/page-data/notes/rows', { content: '旧页面' });
assert.equal(blocked.status, 410);
assert.equal(blocked.body.error.code, 'legacy_page_data_api_removed');
});
test('legacy API path detector does not include the admin namespace', () => {
assert.equal(isLegacyPageDataApiPath('/page-data/notes/rows'), true);
assert.equal(isLegacyPageDataApiPath('/admin/page-data/notes/rows'), false);
});
test('page data routes require login', async () => {
const api = express.Router();
attachPageDataRoutes(api, {
@@ -180,7 +194,7 @@ test('page data routes require login', async () => {
sendError: (res, _req, status, code, message) => res.status(status).json({ error: { code, message } }),
getPageDataService: () => createPageDataService({ resolveWorkspaceRoot: async () => '/tmp' }),
});
const handler = api.stack.find((layer) => layer.route?.path === '/page-data/:dataset')?.route.stack[0].handle;
const handler = api.stack.find((layer) => layer.route?.path === '/admin/page-data/:dataset')?.route.stack[0].handle;
const res = createResponseRecorder();
await handler({ params: { dataset: 'demo' } }, res);
assert.equal(res.statusCode, 401);
+7
View File
@@ -152,6 +152,13 @@ function assertRegisteredDatasetTables(userDataSpace, registryDatasets, usage) {
action: 'read',
});
}
if (requiredUsage.softDelete && !dataset.actions.includes('soft_delete')) {
throw Object.assign(new Error(`dataset「${dataset.name}」未开放软删除`), {
code: 'dataset_action_not_registered',
datasetName: dataset.name,
action: 'soft_delete',
});
}
const actualColumns = userDataSpace.listTableColumns(dataset.table);
if (!actualColumns.length) {
throw Object.assign(new Error(`dataset 对应表不存在:${dataset.table}`), {
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
IMAGE_REF="${IMGPROXY_IMAGE_REF:-darthsim/imgproxy@sha256:3819ccd5ee26b83b27f8184b17a0a0c6f0eb21052efa9f554b23a7c39a3e6a1f}"
IMAGE_TAG="${IMGPROXY_IMAGE_TAG:-tkmind/imgproxy-runtime:20260715-3819ccd5}"
OUT_DIR="${IMGPROXY_RUNTIME_OUT_DIR:-${ROOT}/.runtime/imgproxy}"
DRY_RUN=0
[[ "${1:-}" != "--dry-run" ]] || DRY_RUN=1
mkdir -p "$OUT_DIR"
if [[ "$DRY_RUN" == 1 ]]; then
printf 'image_ref=%s\nimage_tag=%s\nout_dir=%s\n' "$IMAGE_REF" "$IMAGE_TAG" "$OUT_DIR"
exit 0
fi
command -v docker >/dev/null || { echo 'docker is required to build the imgproxy runtime image' >&2; exit 1; }
if ! docker image inspect "$IMAGE_REF" >/dev/null 2>&1; then
docker pull --platform linux/arm64 "$IMAGE_REF"
fi
docker tag "$IMAGE_REF" "$IMAGE_TAG"
docker save "$IMAGE_TAG" | gzip -c > "$OUT_DIR/imgproxy-runtime-image.tar.gz"
(cd "$OUT_DIR" && shasum -a 256 imgproxy-runtime-image.tar.gz > imgproxy-runtime-image.tar.gz.sha256)
printf '%s\n' "$IMAGE_TAG" > "$OUT_DIR/image-tag.txt"
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
const read = (name) => fs.readFileSync(path.join(root, name), 'utf8');
test('imgproxy runtime pins an arm64 image digest', () => {
const dockerfile = read('docker/imgproxy-runtime/Dockerfile');
assert.match(dockerfile, /FROM darthsim\/imgproxy@sha256:[a-f0-9]{64}/);
});
test('builder reuses an already verified digest without a network pull', () => {
const builder = read('scripts/build-imgproxy-runtime-image.sh');
assert.match(builder, /docker image inspect "\$IMAGE_REF"/);
assert.match(builder, /shasum -a 256 imgproxy-runtime-image\.tar\.gz/);
});
test('installer uses a read-only storage mount and both required ports', () => {
const installer = read('scripts/install-imgproxy-runtime-prod.sh');
assert.match(installer, /-p 127\.0\.0\.1:20082:8080/);
assert.match(installer, /\$MINDSPACE_STORAGE_ROOT:\/mnt\/images:ro/);
assert.match(installer, /10\.10\.0\.2/);
assert.match(installer, /IMGPROXY_UPSTREAM/);
assert.match(installer, /DOCKER_BIN/);
assert.match(installer, /seq 1 20/);
});
test('release verifies checksums before loading the image and backs up launch state', () => {
const release = read('scripts/release-imgproxy-runtime-prod.sh');
assert.match(release, /shasum -a 256 -c/);
assert.match(release, /shasum -a 256 "imgproxy-runtime-\$RELEASE_ID\.tar\.gz"/);
assert.match(release, /"\$DOCKER_BIN" load/);
assert.match(release, /DOCKER_BIN/);
assert.match(release, /imgproxy-compat-\$RELEASE_ID\.plist/);
assert.match(release, /img\.tkmind\.cn\/health/);
assert.match(release, /release\/\*/);
assert.match(release, /rev-parse origin\/main/);
});
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Runs only on 103 from a verified release directory. It never downloads images.
set -euo pipefail
ROOT="${ROOT:-/Users/john/Project/Memind}"
RUNTIME_DIR="${IMGPROXY_RUNTIME_DIR:?IMGPROXY_RUNTIME_DIR is required}"
IMAGE_TAG="${IMGPROXY_IMAGE_TAG:?IMGPROXY_IMAGE_TAG is required}"
CONTAINER="${IMGPROXY_CONTAINER:-memind-imgproxy}"
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
DOCKER_BIN="${DOCKER_BIN:-/opt/homebrew/bin/docker}"
PLIST="$HOME/Library/LaunchAgents/cn.tkmind.imgproxy-compat.plist"
GUI="gui/$(id -u)"
set -a
source "$ROOT/.env"
set +a
: "${IMGPROXY_SIGNING_KEY:?missing IMGPROXY_SIGNING_KEY}"
: "${IMGPROXY_SIGNING_SALT:?missing IMGPROXY_SIGNING_SALT}"
: "${MINDSPACE_STORAGE_ROOT:?missing MINDSPACE_STORAGE_ROOT}"
"$DOCKER_BIN" rm -f "$CONTAINER" >/dev/null 2>&1 || true
"$DOCKER_BIN" run -d --name "$CONTAINER" --restart unless-stopped \
-p 127.0.0.1:20082:8080 \
-v "$MINDSPACE_STORAGE_ROOT:/mnt/images:ro" \
-e IMGPROXY_LOCAL_FILESYSTEM_ROOT=/mnt/images \
-e IMGPROXY_KEY="$IMGPROXY_SIGNING_KEY" \
-e IMGPROXY_SALT="$IMGPROXY_SIGNING_SALT" \
-e IMGPROXY_USE_ETAG=true \
-e IMGPROXY_ENABLE_WEBP_DETECTION=true \
"$IMAGE_TAG" >/dev/null
for _ in $(seq 1 20); do
curl -fsS --max-time 2 http://127.0.0.1:20082/health >/dev/null && break
sleep 1
done
curl -fsS --max-time 3 http://127.0.0.1:20082/health >/dev/null
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>cn.tkmind.imgproxy-compat</string>
<key>ProgramArguments</key><array><string>$NODE_BIN</string><string>$RUNTIME_DIR/imgproxy-compat-proxy.mjs</string></array>
<key>WorkingDirectory</key><string>$RUNTIME_DIR</string>
<key>EnvironmentVariables</key><dict><key>IMGPROXY_COMPAT_HOST</key><string>10.10.0.2</string><key>IMGPROXY_COMPAT_PORT</key><string>20081</string><key>IMGPROXY_UPSTREAM</key><string>http://127.0.0.1:20082</string></dict>
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>10</integer>
<key>StandardOutPath</key><string>$HOME/Library/Logs/imgproxy-compat.log</string><key>StandardErrorPath</key><string>$HOME/Library/Logs/imgproxy-compat.log</string>
</dict></plist>
EOF
plutil -lint "$PLIST" >/dev/null
launchctl bootout "$GUI/cn.tkmind.imgproxy" 2>/dev/null || true
launchctl disable "$GUI/cn.tkmind.imgproxy" 2>/dev/null || true
launchctl bootout "$GUI/cn.tkmind.imgproxy-compat" 2>/dev/null || true
launchctl bootstrap "$GUI" "$PLIST"
launchctl enable "$GUI/cn.tkmind.imgproxy-compat"
launchctl kickstart -k "$GUI/cn.tkmind.imgproxy-compat"
for _ in $(seq 1 20); do
curl -fsS --max-time 2 http://10.10.0.2:20081/health >/dev/null && break
sleep 1
done
curl -fsS --max-time 3 http://10.10.0.2:20081/health >/dev/null
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Release the independently versioned imgproxy runtime. Never run this from a
# feature branch; it is intentionally separate from the Portal runtime bundle.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-john@58.38.22.103}"
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
DOCKER_BIN="${DOCKER_BIN:-/opt/homebrew/bin/docker}"
RUNTIME_BASE="$REMOTE_ROOT/imgproxy-runtime"
INCOMING="$REMOTE_ROOT/incoming/imgproxy-runtime"
RELEASE_ID="$(date +%Y%m%d-%H%M%S)-$(git -C "$ROOT" rev-parse --short HEAD)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/imgproxy-runtime-release.XXXXXX")"
cleanup() { rm -rf "$TMP"; }
trap cleanup EXIT
git -C "$ROOT" fetch origin main --quiet
branch="$(git -C "$ROOT" branch --show-current)"
[[ "$branch" == release/* ]] || { echo 'imgproxy production release must run from a release/* branch' >&2; exit 1; }
[[ -z "$(git -C "$ROOT" status --porcelain)" ]] || { echo 'worktree must be clean' >&2; exit 1; }
[[ "$(git -C "$ROOT" rev-parse HEAD)" == "$(git -C "$ROOT" rev-parse origin/main)" ]] || { echo 'main must match origin/main' >&2; exit 1; }
bash "$ROOT/scripts/check-release-ready.sh"
bash "$ROOT/scripts/build-imgproxy-runtime-image.sh"
IMAGE_DIR="$ROOT/.runtime/imgproxy"
for file in imgproxy-runtime-image.tar.gz imgproxy-runtime-image.tar.gz.sha256 image-tag.txt; do
[[ -f "$IMAGE_DIR/$file" ]] || { echo "missing image artifact: $file" >&2; exit 1; }
done
mkdir -p "$TMP/runtime"
cp "$IMAGE_DIR"/* "$TMP/runtime/"
cp "$ROOT/scripts/install-imgproxy-runtime-prod.sh" "$ROOT/scripts/imgproxy-compat-proxy.mjs" "$TMP/runtime/"
chmod 755 "$TMP/runtime/install-imgproxy-runtime-prod.sh"
tar -C "$TMP" -czf "$TMP/imgproxy-runtime-$RELEASE_ID.tar.gz" runtime
(cd "$TMP" && shasum -a 256 "imgproxy-runtime-$RELEASE_ID.tar.gz" > "imgproxy-runtime-$RELEASE_ID.tar.gz.sha256")
ssh -o BatchMode=yes "$HOST" "mkdir -p '$INCOMING'"
scp -q "$TMP/imgproxy-runtime-$RELEASE_ID.tar.gz" "$TMP/imgproxy-runtime-$RELEASE_ID.tar.gz.sha256" "$HOST:$INCOMING/"
ssh -o BatchMode=yes "$HOST" "RELEASE_ID='$RELEASE_ID' RUNTIME_BASE='$RUNTIME_BASE' INCOMING='$INCOMING' bash -s" <<'REMOTE'
set -euo pipefail
archive="$INCOMING/imgproxy-runtime-$RELEASE_ID.tar.gz"
sha="$archive.sha256"
cd "$INCOMING"
shasum -a 256 -c "$sha"
release_dir="$RUNTIME_BASE/releases/$RELEASE_ID"
mkdir -p "$release_dir" "$RUNTIME_BASE/backups"
cp "$HOME/Library/LaunchAgents/cn.tkmind.imgproxy-compat.plist" "$RUNTIME_BASE/backups/imgproxy-compat-$RELEASE_ID.plist" 2>/dev/null || true
tar -xzf "$archive" -C "$release_dir" --strip-components=1
cd "$release_dir"
shasum -a 256 -c imgproxy-runtime-image.tar.gz.sha256
gunzip -c imgproxy-runtime-image.tar.gz | "$DOCKER_BIN" load
image_tag="$(cat image-tag.txt)"
IMGPROXY_RUNTIME_DIR="$release_dir" IMGPROXY_IMAGE_TAG="$image_tag" bash ./install-imgproxy-runtime-prod.sh
ln -sfn "$release_dir" "$RUNTIME_BASE/current"
curl -fsS --max-time 5 http://127.0.0.1:20082/health >/dev/null
curl -fsS --max-time 5 http://10.10.0.2:20081/health >/dev/null
REMOTE
curl -kfsS --max-time 15 https://img.tkmind.cn/health >/dev/null
printf 'imgproxy release verified: %s\n' "$RELEASE_ID"
+20
View File
@@ -0,0 +1,20 @@
export const SEARCH_ERROR_CODES = Object.freeze({ CAPABILITY_DISABLED: 'CAPABILITY_DISABLED', INVALID_REQUEST: 'INVALID_REQUEST', PROVIDER_UNAVAILABLE: 'PROVIDER_UNAVAILABLE', UNSAFE_URL: 'UNSAFE_URL' });
export function validateSearchRequest(input = {}) {
const query = String(input.query ?? '').trim();
if (!query || query.length > 500) return { ok: false, code: SEARCH_ERROR_CODES.INVALID_REQUEST, message: 'query must be 1-500 characters' };
const limit = Math.max(1, Math.min(20, Number(input.limit ?? 10) || 10));
return { ok: true, value: { query, type: String(input.type ?? 'web'), limit } };
}
export function normalizeSearchResult(result = {}, index = 0) {
return { title: String(result.title ?? '').trim(), url: String(result.url ?? '').trim(), snippet: String(result.snippet ?? result.content ?? '').trim().slice(0, 2000), source: String(result.source ?? 'unknown'), rank: index + 1 };
}
export function buildCitations(results = []) {
return results.filter((item) => item.url).map((item, index) => ({ id: `[${index + 1}]`, title: item.title, url: item.url, source: item.source }));
}
export function isSafeHttpUrl(value) {
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !['localhost', '127.0.0.1', '::1'].includes(url.hostname); } catch { return false; }
}
+49 -7
View File
@@ -45,6 +45,7 @@ import { createMindSpaceAuditWriter } from './mindspace-audit.mjs';
import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
import { createMindSpaceService } from './mindspace.mjs';
import { ensureMindSpaceConfig } from './mindspace-config.mjs';
import { createMindSearchConfigService } from './mindsearch-config.mjs';
import { pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
import {
createDownloadConversationPackageArtifactHandler,
@@ -138,8 +139,11 @@ import {
} 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 { maybeRepairPageDataAfterFinish } from './mindspace-page-data-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';
@@ -200,7 +204,7 @@ import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-conf
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
import { createExperienceService } from './experience-service.mjs';
import { attachAsrRoutes } from './asr-proxy.mjs';
import { attachPageDataRoutes } from './page-data-routes.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';
@@ -405,6 +409,8 @@ 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, {
@@ -539,6 +545,7 @@ async function bootstrapUserAuth() {
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();
@@ -736,6 +743,30 @@ async function bootstrapUserAuth() {
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 ?? [])
@@ -743,10 +774,10 @@ async function bootstrapUserAuth() {
.filter(Boolean),
});
return {
errors: violations.map((violation) => ({
errors: [...pageDataErrors, ...violations.map((violation) => ({
code: 'browser_storage_forbidden',
message: `${violation.relativePath} 使用 ${violation.apis.join(', ')}`,
})),
}))],
};
},
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
@@ -2008,10 +2039,14 @@ api.use(async (req, res, next) => {
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) return next();
if (plazaPublic || pageDataPublic || legacyPageDataApi) return next();
return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' });
}
try {
@@ -2019,7 +2054,7 @@ api.use(async (req, res, next) => {
const me = await userAuth.getMe(req.userToken);
if (me) req.currentUser = me;
}
if (plazaPublic || pageDataPublic) return next();
if (plazaPublic || pageDataPublic || legacyPageDataApi) return next();
if (!req.userSession) {
return res.status(401).json({ message: '未授权,请重新登录' });
}
@@ -3286,9 +3321,16 @@ async function syncUserGeneratedPages(userId, { sessionId = null, sinceMs = null
// Agent-run/Finish delivery must stay scoped to the current conversation.
// A stale Page Data page elsewhere in the user's workspace must not turn a
// successfully completed current task into a failed run.
const pageDataRelativePaths = sessionId
const discoveredRelativePaths = sessionId
? await listSessionPublicHtmlRelativePaths(userId, sessionId, { sinceMs })
: null;
// An empty artifact package does not prove that this run wrote no page: a
// normal static-page-publish flow may only leave a public HTML workspace
// file. `null` means "discover current workspace output"; `[]` would tell
// the sync service to inspect nothing and make a completed page run fail.
const pageDataRelativePaths = discoveredRelativePaths?.length
? discoveredRelativePaths
: null;
if (workspacePageDeliver?.syncAndDeliver) {
return await workspacePageDeliver.syncAndDeliver(userId, { pageDataRelativePaths });
}
+1
View File
@@ -15,6 +15,7 @@ export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
export const DEFAULT_USER_SKILLS = {
web: true,
search: true,
'search-enhanced': false,
'schedule-assistant': true,
'service-integration-smoke': true,
'form-builder': true,
+24
View File
@@ -0,0 +1,24 @@
---
name: search-enhanced
description: 可插拔外部搜索编排:优先使用 MindSearch,失败时回退现有 web/search 能力
---
# 外部增强搜索
这是一个可选的搜索编排 Skill,不替代现有 `web` 或工作区 `search` Skill。
## 使用规则
1. 只有当前会话策略挂载了 `tkmind-search` 且用户拥有 `search_external` 能力时,才调用 `tkmind_search``tkmind_read`
2. `web` / `news` 使用 SearXNG`code` 使用 GitHub Code`read` 使用 Reader。
3. 搜索结果必须保留标题、摘要、URL、Provider 来源和引用编号。
4. Provider 超时、限流、未配置或返回错误时,立即回退现有 `web_search` / `fetch_url`;不要阻断回答,也不要声称已使用外部增强搜索。
5. 外部结果只作为补充证据,不写入用户长期记忆,不改变会话上下文和原有内容生成策略。
## 推荐请求形状
```json
{"query":"...","type":"web|news|code","limit":10}
```
读取 URL 前必须确认是公开的 HTTP(S) 地址,禁止访问 localhost、内网地址、云元数据地址和未经用户允许的敏感页面。
+17
View File
@@ -0,0 +1,17 @@
name: search-enhanced
description: 可插拔外部搜索编排:优先使用 MindSearch,失败时回退现有 web/search 能力
version: 0.1.0
executors:
- goose
trigger:
keywords:
- 最新资料
- 实时搜索
- 外部搜索
- 搜索新闻
- 搜索代码
- search the web
- latest information
router:
priority: 30
promptKey: search-enhanced
+12 -12
View File
@@ -1483,24 +1483,24 @@ export async function listOwnerPageDataPolicies(): Promise<
updatedAt: number;
}>;
};
}>('/page-data/policies');
}>('/admin/page-data/policies');
return result.data.policies;
}
export function buildPageDataExportUrl(dataset: string, format: 'json' | 'csv' = 'json') {
const params = new URLSearchParams({ format });
return `/api/page-data/${encodeURIComponent(dataset)}/export?${params.toString()}`;
return `/api/admin/page-data/${encodeURIComponent(dataset)}/export?${params.toString()}`;
}
export async function listPageDataDatasets(): Promise<PageDataDatasetSummary[]> {
const result = await apiFetch<{ data: { datasets: PageDataDatasetSummary[] } }>('/page-data');
const result = await apiFetch<{ data: { datasets: PageDataDatasetSummary[] } }>('/admin/page-data');
return result.data.datasets;
}
export async function getPageDataPolicy(pageId: string): Promise<PageDataAccessPolicy | null> {
try {
const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>(
`/page-data/policies/${encodeURIComponent(pageId)}`,
`/admin/page-data/policies/${encodeURIComponent(pageId)}`,
);
return result.data.policy;
} catch (error) {
@@ -1522,7 +1522,7 @@ export async function applyPageDataPublishPolicy(
},
): Promise<PageDataAccessPolicy> {
const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>(
`/page-data/policies/${encodeURIComponent(pageId)}/apply-publish`,
`/admin/page-data/policies/${encodeURIComponent(pageId)}/apply-publish`,
{
method: 'POST',
body: JSON.stringify({
@@ -1539,7 +1539,7 @@ export async function savePageDataPolicy(
policy: Partial<PageDataAccessPolicy>,
): Promise<PageDataAccessPolicy> {
const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>(
`/page-data/policies/${encodeURIComponent(pageId)}`,
`/admin/page-data/policies/${encodeURIComponent(pageId)}`,
{
method: 'PUT',
body: JSON.stringify(policy),
@@ -1550,7 +1550,7 @@ export async function savePageDataPolicy(
export async function getPageDataOpsOverview(pageId: string): Promise<PageDataOpsOverview> {
const result = await apiFetch<{ data: PageDataOpsOverview }>(
`/page-data/policies/${encodeURIComponent(pageId)}/ops`,
`/admin/page-data/policies/${encodeURIComponent(pageId)}/ops`,
);
return result.data;
}
@@ -1564,7 +1564,7 @@ export async function listPageDataLogs(
if (input.offset != null) params.set('offset', String(input.offset));
const query = params.toString();
const result = await apiFetch<{ data: { pageId: string; logs: PageDataLogEntry[]; count: number } }>(
`/page-data/policies/${encodeURIComponent(pageId)}/logs${query ? `?${query}` : ''}`,
`/admin/page-data/policies/${encodeURIComponent(pageId)}/logs${query ? `?${query}` : ''}`,
);
return result.data;
}
@@ -1574,7 +1574,7 @@ export async function revokePageDataTokens(
input: { revokeAll?: boolean; token?: string },
): Promise<{ pageId: string; revokedCount: number; revokeAll: boolean }> {
const result = await apiFetch<{ data: { pageId: string; revokedCount: number; revokeAll: boolean } }>(
`/page-data/policies/${encodeURIComponent(pageId)}/tokens/revoke`,
`/admin/page-data/policies/${encodeURIComponent(pageId)}/tokens/revoke`,
{
method: 'POST',
body: JSON.stringify({
@@ -1592,7 +1592,7 @@ export async function resetPageDataPassword(
): Promise<{ pageId: string; passwordReset: boolean; revokedSessions: number }> {
const result = await apiFetch<{
data: { pageId: string; passwordReset: boolean; revokedSessions: number };
}>(`/page-data/policies/${encodeURIComponent(pageId)}/password/reset`, {
}>(`/admin/page-data/policies/${encodeURIComponent(pageId)}/password/reset`, {
method: 'POST',
body: JSON.stringify({ password }),
});
@@ -1604,7 +1604,7 @@ export async function closePageDataDataset(
dataset: string,
): Promise<{ pageId: string; dataset: string; closed: boolean }> {
const result = await apiFetch<{ data: { pageId: string; dataset: string; closed: boolean } }>(
`/page-data/policies/${encodeURIComponent(pageId)}/datasets/${encodeURIComponent(dataset)}/close`,
`/admin/page-data/policies/${encodeURIComponent(pageId)}/datasets/${encodeURIComponent(dataset)}/close`,
{ method: 'POST' },
);
return result.data;
@@ -1615,7 +1615,7 @@ export async function restorePageDataRow(
rowId: number | string,
): Promise<{ restored: boolean; row: Record<string, unknown> }> {
const result = await apiFetch<{ data: { restored: boolean; row: Record<string, unknown> } }>(
`/page-data/${encodeURIComponent(dataset)}/rows/${encodeURIComponent(String(rowId))}/restore`,
`/admin/page-data/${encodeURIComponent(dataset)}/rows/${encodeURIComponent(String(rowId))}/restore`,
{ method: 'POST' },
);
return result.data;
+6 -6
View File
@@ -54,6 +54,7 @@ import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
import {
reconcileSessionEventRequestContext,
resolvePostAgentRunChatState,
shouldKeepStreamingAfterRunError,
shouldPromoteSessionIdToStreaming,
} from '../../chat-agent-run-gate.mjs';
import { mergeConversationSnapshot } from '../../chat-finish-sync.mjs';
@@ -233,11 +234,6 @@ function mergeMessagePages(older: Message[], current: Message[]): Message[] {
return merged;
}
function isAmbiguousReplySubmitError(err: unknown) {
if (!(err instanceof ApiError)) return true;
return err.status === 0 || err.status === 409 || err.status >= 500;
}
const SESSION_LIST_RETRY_ATTEMPTS = 3;
const SESSION_LIST_RETRY_DELAY_MS = 450;
@@ -1619,7 +1615,11 @@ export function useTKMindChat(
}
}
} catch (err) {
if (activeSessionId && isAmbiguousReplySubmitError(err)) {
if (
activeSessionId &&
err instanceof ApiError &&
shouldKeepStreamingAfterRunError(err.status)
) {
subscribeToSession(activeSessionId);
setChatState('streaming');
setError(null);
+47
View File
@@ -0,0 +1,47 @@
import readline from 'node:readline';
import { normalizeMindSearchConfig } from './mindsearch-config.mjs';
import { SEARCH_ERROR_CODES, validateSearchRequest } from './search-capability.mjs';
import { readSafeUrl, searchGithubCode, searchSearxng } from './mindsearch-providers.mjs';
const config = normalizeMindSearchConfig({
enabled: process.env.TKMIND_SEARCH_ENABLED,
mode: process.env.TKMIND_SEARCH_MODE || (/^(1|true|yes|on)$/i.test(process.env.TKMIND_SEARCH_ENABLED ?? '') ? 'assist' : 'off'),
providers: {
searxng: process.env.TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED,
github: process.env.TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED,
reader: process.env.TKMIND_SEARCH_READER_ENABLED,
},
});
const tools = [
{ name: 'tkmind_search', description: 'Optional external search enhancement; existing web search remains primary.', inputSchema: { type: 'object', properties: { query: { type: 'string' }, type: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] } },
{ name: 'tkmind_read', description: 'Optional safe URL reader for search citations.', inputSchema: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } },
];
function response(id, result) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); }
function error(id, code, message) { response(id, { isError: true, content: [{ type: 'text', text: `${code}: ${message}` }], structuredContent: { code, message } }); }
function handle(message) {
const { id, method, params = {} } = message;
if (method === 'initialize') return response(id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'tkmind-search', version: '0.1.0' } });
if (method === 'notifications/initialized') return;
if (method === 'tools/list') return response(id, { tools });
if (method !== 'tools/call') return error(id, 'METHOD_NOT_FOUND', `unsupported method: ${method}`);
if (!config.enabled || config.mode === 'off') return error(id, SEARCH_ERROR_CODES.CAPABILITY_DISABLED, 'MindSearch is disabled; existing search capabilities are unchanged.');
const name = params.name;
if (name === 'tkmind_search') {
const checked = validateSearchRequest(params.arguments ?? {});
if (!checked.ok) return error(id, checked.code, checked.message);
if (checked.value.type === 'code' && config.providers.github) {
return searchGithubCode(checked.value.query, { limit: checked.value.limit }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: 'github' }) }], structuredContent: { results, query: checked.value.query, provider: 'github' } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
}
if (config.providers.searxng) {
return searchSearxng(checked.value.query, { limit: checked.value.limit }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: 'searxng' }) }], structuredContent: { results, query: checked.value.query, provider: 'searxng' } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
}
return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No search provider is configured.');
}
if (name === 'tkmind_read' && config.providers.reader) return readSafeUrl(params.arguments?.url).then((result) => response(id, { content: [{ type: 'text', text: result.content }], structuredContent: result })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No reader provider is configured.');
}
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line) => { try { handle(JSON.parse(line)); } catch { error(null, SEARCH_ERROR_CODES.INVALID_REQUEST, 'invalid JSON-RPC request'); } });
+4 -1
View File
@@ -141,6 +141,7 @@ export function createUserAuth(pool, options = {}) {
const provisionUserDataSpace = typeof options.provisionUserDataSpace === 'function'
? options.provisionUserDataSpace
: null;
const getMindSearchConfig = typeof options.getMindSearchConfig === 'function' ? options.getMindSearchConfig : null;
const sessions = new Map();
const loginFailures = new Map();
@@ -1853,10 +1854,11 @@ export function createUserAuth(pool, options = {}) {
if (!user) throw new Error('用户不存在');
const capabilityState = await resolveUserCapabilities(user);
const policyState = await resolveUserPolicies(user);
const mindSearchConfig = getMindSearchConfig ? await getMindSearchConfig({ userId, user }) : null;
await syncUserSkillsForUser(user);
if (capabilityState.unrestricted) {
return {
...buildAgentExtensionPolicy(capabilityState.capabilities, { unrestricted: true, toolMode }),
...buildAgentExtensionPolicy(capabilityState.capabilities, { unrestricted: true, toolMode, mindSearchConfig }),
policies: {},
unrestricted: true,
toolMode,
@@ -1900,6 +1902,7 @@ export function createUserAuth(pool, options = {}) {
policies: policyState.policies,
sandboxMcp,
toolMode,
mindSearchConfig,
}),
capabilities: effectiveCapabilities,
policies: policyState.policies,
+13
View File
@@ -674,6 +674,18 @@ export function createUserDataSpaceService(options = {}) {
return { dataset, id, deleted: true };
}
async function hardDeleteRowForDataset(dataset, rowId, meta = {}) {
assertDatasetAction(dataset, 'hard_delete');
const table = assertSafeSqlIdentifier(dataset.table, '表名');
const id = Number(rowId);
if (!Number.isFinite(id) || id <= 0) {
throw Object.assign(new Error('行 id 无效'), { code: 'invalid_row_id' });
}
const scopeClause = meta.rowScope?.whereClause ? ` AND ${meta.rowScope.whereClause}` : '';
await executeSql(`DELETE FROM "${table}" WHERE id = ${id}${scopeClause};`);
return { dataset, id, deleted: true, permanentlyDeleted: true };
}
async function restoreSoftDeletedRowForDataset(dataset, rowId) {
const table = assertSafeSqlIdentifier(dataset.table, '表名');
const id = Number(rowId);
@@ -795,6 +807,7 @@ export function createUserDataSpaceService(options = {}) {
updateRowForDataset,
softDeleteDatasetRow,
softDeleteRowForDataset,
hardDeleteRowForDataset,
restoreSoftDeletedRowForDataset,
restoreSoftDeletedRow,
exportDatasetRows,