Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07f1ad0a6e | |||
| 3695fecbb6 | |||
| 72f1266078 | |||
| dd0014db83 | |||
| e2d850c2e9 | |||
| 3eee44ef5b | |||
| 87b03f7848 | |||
| a07cf07861 | |||
| 72fd88219c | |||
| 192548b0b9 | |||
| 27b11894f2 | |||
| ae9090948a |
@@ -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(
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
reconcileSessionEventRequestContext,
|
||||
resolvePostAgentRunChatState,
|
||||
shouldKeepStreamingAfterRunError,
|
||||
shouldPromoteSessionIdToStreaming,
|
||||
} from './chat-agent-run-gate.mjs';
|
||||
|
||||
@@ -36,6 +37,25 @@ test('resolvePostAgentRunChatState idles after worker-side agent run success', (
|
||||
);
|
||||
});
|
||||
|
||||
test('completed run wins when the direct-chat snapshot is not ready', () => {
|
||||
assert.equal(
|
||||
resolvePostAgentRunChatState({
|
||||
chatState: 'streaming',
|
||||
finishedViaPortalDirectChat: false,
|
||||
agentRunSucceeded: true,
|
||||
}),
|
||||
'idle',
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -125,6 +125,27 @@ export async function ensureAssetGatewaySchema(pool) {
|
||||
`);
|
||||
}
|
||||
|
||||
/** Additive only: does not read, move, or mutate user page/data records. */
|
||||
export async function ensurePageDeliveryContractSchema(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS h5_page_delivery_contracts (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
request_id VARCHAR(128) NOT NULL,
|
||||
workspace_relative_path VARCHAR(512) NOT NULL,
|
||||
data_mode ENUM('static', 'pg_required') NOT NULL DEFAULT 'static',
|
||||
status ENUM('preparing', 'ready', 'failed') NOT NULL DEFAULT 'preparing',
|
||||
failure_reason VARCHAR(1000) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
ready_at BIGINT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_h5_delivery_contract_request_path (user_id, request_id, workspace_relative_path),
|
||||
KEY idx_h5_delivery_contract_route (user_id, workspace_relative_path, updated_at),
|
||||
CONSTRAINT fk_h5_delivery_contract_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
|
||||
export async function migrateSchema(pool) {
|
||||
const renames = [
|
||||
['h5_user_sessions', 'goose_session_id', 'agent_session_id'],
|
||||
@@ -249,6 +270,7 @@ export async function migrateSchema(pool) {
|
||||
// Optional asset capability control plane. These rows configure no runtime
|
||||
// worker by themselves; the existing chat/page path remains independent.
|
||||
await ensureAssetGatewaySchema(pool);
|
||||
await ensurePageDeliveryContractSchema(pool);
|
||||
|
||||
// Shared experience store (etat C). Keep in sync with schema.sql.
|
||||
await pool.query(`
|
||||
|
||||
@@ -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
@@ -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(无需平台登录)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export function normalizeDeliveryRelativePath(value) {
|
||||
const path = String(value ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (!path.startsWith('public/') || !path.toLowerCase().endsWith('.html')) return null;
|
||||
if (path.split('/').some((part) => !part || part === '.' || part === '..')) return null;
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function preparePageDeliveryContract({ pool, userId, requestId, relativePath, pgRequired = false }) {
|
||||
const workspaceRelativePath = normalizeDeliveryRelativePath(relativePath);
|
||||
if (!pool || !userId || !requestId || !workspaceRelativePath) return null;
|
||||
const now = Date.now();
|
||||
const id = crypto.randomUUID();
|
||||
await pool.query(
|
||||
`INSERT INTO h5_page_delivery_contracts
|
||||
(id, user_id, request_id, workspace_relative_path, data_mode, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE data_mode = VALUES(data_mode), status = 'preparing', failure_reason = NULL, updated_at = VALUES(updated_at)`,
|
||||
[id, userId, requestId, workspaceRelativePath, pgRequired ? 'pg_required' : 'static', now, now],
|
||||
);
|
||||
return { id, userId, requestId, workspaceRelativePath, dataMode: pgRequired ? 'pg_required' : 'static', status: 'preparing' };
|
||||
}
|
||||
|
||||
export async function getPageDeliveryContract({ pool, userId, relativePath }) {
|
||||
const workspaceRelativePath = normalizeDeliveryRelativePath(relativePath);
|
||||
if (!pool || !userId || !workspaceRelativePath) return null;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, data_mode, status, failure_reason FROM h5_page_delivery_contracts
|
||||
WHERE user_id = ? AND workspace_relative_path = ?
|
||||
ORDER BY updated_at DESC LIMIT 1`,
|
||||
[userId, workspaceRelativePath],
|
||||
);
|
||||
return rows?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function markPageDeliveryContractReady({ pool, userId, relativePath }) {
|
||||
const workspaceRelativePath = normalizeDeliveryRelativePath(relativePath);
|
||||
if (!pool || !userId || !workspaceRelativePath) return false;
|
||||
const now = Date.now();
|
||||
const [result] = await pool.query(
|
||||
`UPDATE h5_page_delivery_contracts
|
||||
SET status = 'ready', ready_at = ?, failure_reason = NULL, updated_at = ?
|
||||
WHERE user_id = ? AND workspace_relative_path = ? AND status = 'preparing'`,
|
||||
[now, now, userId, workspaceRelativePath],
|
||||
);
|
||||
return Number(result?.affectedRows ?? 0) > 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getPageDeliveryContract,
|
||||
markPageDeliveryContractReady,
|
||||
normalizeDeliveryRelativePath,
|
||||
preparePageDeliveryContract,
|
||||
} from './mindspace-delivery-contract.mjs';
|
||||
|
||||
test('normalizes only safe public HTML delivery paths', () => {
|
||||
assert.equal(normalizeDeliveryRelativePath('public/survey.html'), 'public/survey.html');
|
||||
assert.equal(normalizeDeliveryRelativePath('/public/nested/report.html'), 'public/nested/report.html');
|
||||
assert.equal(normalizeDeliveryRelativePath('public/../secret.html'), null);
|
||||
assert.equal(normalizeDeliveryRelativePath('public/report.js'), null);
|
||||
});
|
||||
|
||||
test('contract lifecycle writes preparing then ready against the same route key', async () => {
|
||||
const calls = [];
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
calls.push({ sql, params });
|
||||
if (sql.includes('SELECT id, data_mode')) return [[{ id: 'contract-1', data_mode: 'pg_required', status: 'preparing' }]];
|
||||
if (sql.includes('UPDATE h5_page_delivery_contracts')) return [{ affectedRows: 1 }];
|
||||
return [{ affectedRows: 1 }];
|
||||
},
|
||||
};
|
||||
const contract = await preparePageDeliveryContract({
|
||||
pool,
|
||||
userId: 'user-1',
|
||||
requestId: 'request-1',
|
||||
relativePath: 'public/form.html',
|
||||
pgRequired: true,
|
||||
});
|
||||
assert.equal(contract.status, 'preparing');
|
||||
assert.equal(contract.dataMode, 'pg_required');
|
||||
const prepareCall = calls.find((call) => call.sql.includes('INSERT INTO h5_page_delivery_contracts'));
|
||||
assert.equal(prepareCall.params[4], 'pg_required');
|
||||
const current = await getPageDeliveryContract({ pool, userId: 'user-1', relativePath: 'public/form.html' });
|
||||
assert.equal(current.status, 'preparing');
|
||||
assert.equal(await markPageDeliveryContractReady({ pool, userId: 'user-1', relativePath: 'public/form.html' }), true);
|
||||
assert.ok(calls.some((call) => call.sql.includes("status = 'ready'")));
|
||||
});
|
||||
@@ -31,6 +31,16 @@ const LOCAL_STORAGE_FALLBACK_HINT_PATTERN = /fallback\s*到\s*localStorage|local
|
||||
|
||||
const repairAttemptsBySession = new Map();
|
||||
|
||||
/**
|
||||
* Explicit user choice wins over fallible text-intent classification. The UI
|
||||
* records this on the original user message before any agent execution starts.
|
||||
*/
|
||||
function isPgRequiredByMessage(messages = []) {
|
||||
return messages.some(
|
||||
(message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true,
|
||||
);
|
||||
}
|
||||
|
||||
function envFlag(value, fallback = false) {
|
||||
const raw = String(value ?? '').trim().toLowerCase();
|
||||
if (!raw) return fallback;
|
||||
@@ -324,7 +334,8 @@ export function evaluatePageDataFinishGuard({
|
||||
requestStartedAt = 0,
|
||||
} = {}) {
|
||||
const resolvedAgentText = resolvePageDataGuardAgentText({ agentText, messages });
|
||||
const pageDataIntent = isPageDataIntent(resolvedAgentText);
|
||||
const pgRequired = isPgRequiredByMessage(messages);
|
||||
const pageDataIntent = pgRequired || isPageDataIntent(resolvedAgentText);
|
||||
const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir);
|
||||
const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt });
|
||||
const recentBinds = extractRecentPageDataBindTargets(messages, { sinceMs: requestStartedAt });
|
||||
@@ -359,10 +370,11 @@ export function evaluatePageDataFinishGuard({
|
||||
unboundFiles.length > 0 ||
|
||||
(relevantFiles.length === 0 &&
|
||||
extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 &&
|
||||
usedPageDataCollectSkill(messages)));
|
||||
(usedPageDataCollectSkill(messages) || pgRequired)));
|
||||
|
||||
return {
|
||||
pageDataIntent,
|
||||
pgRequired,
|
||||
structuralPageData: structuralFiles.length > 0,
|
||||
relevantFiles,
|
||||
htmlIssues,
|
||||
@@ -396,13 +408,13 @@ export async function evaluatePageDataFinishGuardAsync({
|
||||
findPageByRelativePath,
|
||||
});
|
||||
const needsRepair =
|
||||
base.structuralPageData &&
|
||||
(base.structuralPageData || base.pgRequired) &&
|
||||
(base.htmlIssues.length > 0 ||
|
||||
unboundFiles.length > 0 ||
|
||||
(base.pageDataIntent &&
|
||||
base.relevantFiles.length === 0 &&
|
||||
extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 &&
|
||||
usedPageDataCollectSkill(messages)));
|
||||
(usedPageDataCollectSkill(messages) || base.pgRequired)));
|
||||
|
||||
return {
|
||||
...base,
|
||||
@@ -425,7 +437,7 @@ export function buildPageDataCollectRepairPrompt({
|
||||
unboundFiles = [],
|
||||
} = {}) {
|
||||
const lines = [
|
||||
'【系统补绑请求】检测到 Page Data 问卷/数据页交付不完整。请立即按 page-data-collect 技能修复:',
|
||||
'【系统交付门禁】本轮用户已要求使用专属 PostgreSQL 数据空间,但 Page Data 页面交付尚未验证完成。请立即按 page-data-collect 技能修复:',
|
||||
'1. load_skill → page-data-collect',
|
||||
'2. 确保 public/*.html 引入 /assets/page-data-client.js,且 JS 使用 MindSpacePageData.createClient({ apiBase: "/api" })',
|
||||
'3. 禁止 /api/page-data/ 旁路、localStorage 或自建后端',
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -116,6 +123,27 @@ test('evaluatePageDataFinishGuard detects unbound page data html', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit PG choice enables the delivery gate without relying on intent text', () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-explicit-pg-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||||
const evaluation = evaluatePageDataFinishGuard({
|
||||
publishDir,
|
||||
agentText: '做一个很好看的介绍页面',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
metadata: { memindRun: { pgRequired: true } },
|
||||
content: [{ type: 'text', text: '做一个很好看的介绍页面' }],
|
||||
}],
|
||||
});
|
||||
assert.equal(evaluation.pgRequired, true);
|
||||
assert.equal(evaluation.pageDataIntent, true);
|
||||
assert.equal(evaluation.needsRepair, true);
|
||||
} finally {
|
||||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('finish guard blocks localStorage ledger generated from ordinary user language', () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-ledger-'));
|
||||
const text = '帮我做一个家庭记账页面,我每天可以记录日期、收入、支出、分类和备注,日期默认当天。再做一个管理页面,可以查看所有记录和收支汇总。';
|
||||
|
||||
@@ -429,6 +429,19 @@ export function materializePublicHtmlWritesFromSessionEvent(
|
||||
return { materialized: [], skipped: [] };
|
||||
}
|
||||
|
||||
/** Read the public HTML targets from a stream event without writing them. */
|
||||
export function collectPublicHtmlWritePathsFromSessionEvent(event, { publishDir, recentCount = 20 } = {}) {
|
||||
if (!event || !publishDir) return [];
|
||||
const messages = event.type === 'Message' && event.message
|
||||
? [event.message]
|
||||
: event.type === 'UpdateConversation' && Array.isArray(event.conversation)
|
||||
? event.conversation.slice(-Math.max(1, recentCount))
|
||||
: [];
|
||||
return [...new Set(extractPublicHtmlWriteArtifacts(messages, { publishDir })
|
||||
.map((artifact) => artifact.relativePath)
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
function messageHasPublicHtmlToolRequest(message, { publishDir = null } = {}) {
|
||||
if (extractPublicHtmlWriteArtifacts([message], { publishDir }).length > 0) {
|
||||
return true;
|
||||
|
||||
@@ -12,6 +12,28 @@ export function htmlUsesExternalScriptSrc(html) {
|
||||
return /<script\b[^>]*\bsrc\s*=/i.test(String(html ?? ''));
|
||||
}
|
||||
|
||||
function buildHybridPublishedPageCsp(html, { extraScriptUrls = [] } = {}) {
|
||||
const scriptSrc = scriptSrcDirective({
|
||||
inline: true,
|
||||
urls: [
|
||||
...(htmlUsesExternalScriptSrc(html) ? ["'self'"] : []),
|
||||
...extraScriptUrls,
|
||||
],
|
||||
});
|
||||
return [
|
||||
"default-src 'none'",
|
||||
"style-src 'unsafe-inline' https:",
|
||||
"img-src 'self' data: http: https:",
|
||||
"font-src 'self' https: data:",
|
||||
"connect-src 'self'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
"script-src-attr 'unsafe-inline'",
|
||||
scriptSrc,
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
export function publishedPageCsp(
|
||||
html,
|
||||
{ embed = false, raw = false, wechatShare = false, scriptHashes = [] } = {},
|
||||
@@ -21,27 +43,10 @@ export function publishedPageCsp(
|
||||
return publishedPageCspForEmbed(true);
|
||||
}
|
||||
if (wechatShare && isFullHtml) {
|
||||
const scriptSrc = scriptSrcDirective({
|
||||
inline: true,
|
||||
urls: [
|
||||
...(htmlUsesExternalScriptSrc(html) ? ["'self'"] : []),
|
||||
'https://res.wx.qq.com',
|
||||
],
|
||||
});
|
||||
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrc}`;
|
||||
return buildHybridPublishedPageCsp(html, { extraScriptUrls: ['https://res.wx.qq.com'] });
|
||||
}
|
||||
if (raw && isFullHtml) {
|
||||
const scriptSrc = scriptSrcDirective({
|
||||
inline: true,
|
||||
urls: htmlUsesExternalScriptSrc(html) ? ["'self'"] : [],
|
||||
});
|
||||
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrc}`;
|
||||
}
|
||||
if (isFullHtml) {
|
||||
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({
|
||||
hashes: scriptHashes,
|
||||
urls: htmlUsesExternalScriptSrc(html) ? ["'self'"] : [],
|
||||
})}`;
|
||||
if ((raw || isFullHtml) && isFullHtml) {
|
||||
return buildHybridPublishedPageCsp(html);
|
||||
}
|
||||
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
|
||||
}
|
||||
|
||||
@@ -7,26 +7,33 @@ const PAGE_DATA_HTML = `<!doctype html><html><head></head><body>
|
||||
<script>var client = MindSpacePageData.createClient({ apiBase: '/api' });</script>
|
||||
</body></html>`;
|
||||
|
||||
test('publishedPageCsp raw mode allows same-origin external scripts for page-data-client.js', () => {
|
||||
const csp = publishedPageCsp(PAGE_DATA_HTML, { raw: true });
|
||||
const INLINE_ONCLICK_HTML = `<!doctype html><html><body>
|
||||
<button id="btn" onclick="document.body.dataset.clicked='1'">点我</button>
|
||||
</body></html>`;
|
||||
|
||||
test('publishedPageCsp default full html uses hybrid inline policy with self scripts', () => {
|
||||
const csp = publishedPageCsp(PAGE_DATA_HTML);
|
||||
assert.match(csp, /script-src 'unsafe-inline' 'self'/);
|
||||
assert.match(csp, /script-src-attr 'unsafe-inline'/);
|
||||
assert.doesNotMatch(csp, /'sha256-/);
|
||||
});
|
||||
|
||||
test('publishedPageCsp raw mode keeps inline-only pages restricted', () => {
|
||||
const html = '<!doctype html><html><body><script>console.log(1)</script></body></html>';
|
||||
const csp = publishedPageCsp(html, { raw: true });
|
||||
test('publishedPageCsp default full html allows dynamic onclick handlers', () => {
|
||||
const csp = publishedPageCsp(INLINE_ONCLICK_HTML);
|
||||
assert.match(csp, /script-src 'unsafe-inline'/);
|
||||
assert.doesNotMatch(csp, /script-src 'unsafe-inline' 'self'/);
|
||||
assert.match(csp, /script-src-attr 'unsafe-inline'/);
|
||||
});
|
||||
|
||||
test('publishedPageCsp non-raw full html allows self when external scripts are present', () => {
|
||||
const csp = publishedPageCsp(PAGE_DATA_HTML, { raw: false });
|
||||
assert.match(csp, /script-src 'self'/);
|
||||
test('publishedPageCsp raw mode matches default hybrid policy for full html', () => {
|
||||
const rawCsp = publishedPageCsp(PAGE_DATA_HTML, { raw: true });
|
||||
const defaultCsp = publishedPageCsp(PAGE_DATA_HTML, { raw: false });
|
||||
assert.equal(rawCsp, defaultCsp);
|
||||
});
|
||||
|
||||
test('publishedPageCsp wechatShare mode allows self when page-data-client.js is referenced', () => {
|
||||
test('publishedPageCsp wechatShare mode allows self and wx bridge scripts', () => {
|
||||
const csp = publishedPageCsp(PAGE_DATA_HTML, { wechatShare: true });
|
||||
assert.match(csp, /script-src 'unsafe-inline' 'self' https:\/\/res\.wx\.qq\.com/);
|
||||
assert.match(csp, /script-src-attr 'unsafe-inline'/);
|
||||
});
|
||||
|
||||
test('publishedPageCsp wechatShare mode keeps inline-only pages without self', () => {
|
||||
|
||||
@@ -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",
|
||||
@@ -64,6 +65,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:public-page-interaction": "node scripts/verify-public-page-interaction.mjs",
|
||||
"verify:page-data": "node --test 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:page-data-delivery": "node scripts/repair-page-data-workspace-bindings.mjs --dry-run",
|
||||
"repair:page-data-bindings": "node scripts/repair-page-data-workspace-bindings.mjs",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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}`), {
|
||||
|
||||
+19
@@ -171,6 +171,25 @@ CREATE TABLE IF NOT EXISTS h5_page_records (
|
||||
CONSTRAINT fk_h5_page_cover_asset FOREIGN KEY (cover_image_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Delivery contracts are separate from page/publication lifecycle records.
|
||||
-- A contract gates only new agent-delivered workspace files; historical public
|
||||
-- files without a contract remain accessible for backward compatibility.
|
||||
CREATE TABLE IF NOT EXISTS h5_page_delivery_contracts (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
request_id VARCHAR(128) NOT NULL,
|
||||
workspace_relative_path VARCHAR(512) NOT NULL,
|
||||
data_mode ENUM('static', 'pg_required') NOT NULL DEFAULT 'static',
|
||||
status ENUM('preparing', 'ready', 'failed') NOT NULL DEFAULT 'preparing',
|
||||
failure_reason VARCHAR(1000) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
ready_at BIGINT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_h5_delivery_contract_request_path (user_id, request_id, workspace_relative_path),
|
||||
KEY idx_h5_delivery_contract_route (user_id, workspace_relative_path, updated_at),
|
||||
CONSTRAINT fk_h5_delivery_contract_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_page_versions (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
page_id CHAR(36) NOT NULL,
|
||||
|
||||
@@ -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"
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify MindSpace published pages under real HTTP CSP — interactions, not just HTTP 200.
|
||||
*
|
||||
* Requires: local Portal (pnpm dev) + playwright.
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const base = process.env.VERIFY_BASE_URL || 'http://127.0.0.1:8081';
|
||||
const userId = process.env.VERIFY_USER_ID || '1e655eff-69ce-4f56-9d74-b02883e4112a';
|
||||
|
||||
const checks = [];
|
||||
|
||||
function record(name, ok, details = {}) {
|
||||
checks.push({ name, ok: Boolean(ok), ...details });
|
||||
const mark = ok ? 'PASS' : 'FAIL';
|
||||
console.log(`${mark} ${name}${details.detail ? ` — ${details.detail}` : ''}`);
|
||||
}
|
||||
|
||||
function pageUrl(relativePath) {
|
||||
return `${base}/${PUBLISH_ROOT_DIR}/${userId}/${relativePath}`;
|
||||
}
|
||||
|
||||
const ONCLICK_FIXTURE = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>onclick fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<button id="btn" onclick="document.body.dataset.clicked='1'">点我</button>
|
||||
<div id="status">idle</div>
|
||||
<script>
|
||||
document.getElementById('btn').addEventListener('click', function () {
|
||||
document.getElementById('status').textContent = 'listener-ok';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const ADD_EVENT_LISTENER_FIXTURE = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><title>listener fixture</title></head>
|
||||
<body>
|
||||
<button id="btn">点我</button>
|
||||
<div id="status">idle</div>
|
||||
<script>
|
||||
document.getElementById('btn').addEventListener('click', function () {
|
||||
document.body.dataset.clicked = '1';
|
||||
document.getElementById('status').textContent = 'ok';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
async function writeFixture(relativePath, html) {
|
||||
const diskPath = path.join(repoRoot, PUBLISH_ROOT_DIR, userId, relativePath);
|
||||
await fs.mkdir(path.dirname(diskPath), { recursive: true });
|
||||
await fs.writeFile(diskPath, html, 'utf8');
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
const { chromium } = await import('playwright');
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
return browser;
|
||||
}
|
||||
|
||||
function attachDiagnostics(page, label) {
|
||||
const cspViolations = [];
|
||||
page.on('console', (message) => {
|
||||
const text = message.text();
|
||||
if (/Content Security Policy|Refused to execute|blocked by CSP/i.test(text)) {
|
||||
cspViolations.push(`${label}: ${text}`);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => {
|
||||
cspViolations.push(`${label}: pageerror ${error.message}`);
|
||||
});
|
||||
return cspViolations;
|
||||
}
|
||||
|
||||
async function testFixturePage(browser, relativePath, { clickSelector, evaluateReady }) {
|
||||
const page = await browser.newPage();
|
||||
const violations = attachDiagnostics(page, relativePath);
|
||||
await page.goto(pageUrl(relativePath), { waitUntil: 'networkidle', timeout: 25_000 });
|
||||
await page.click(clickSelector);
|
||||
await page.waitForTimeout(250);
|
||||
const ready = await page.evaluate(evaluateReady);
|
||||
const csp = (await page.evaluate(() => document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.content))
|
||||
?? '';
|
||||
const headerProbe = await fetch(pageUrl(relativePath)).then((res) => res.headers.get('content-security-policy') ?? '');
|
||||
await page.close();
|
||||
return { ready, violations, csp: headerProbe || csp };
|
||||
}
|
||||
|
||||
async function testStickyNoteInteractions(browser) {
|
||||
const page = await browser.newPage();
|
||||
const violations = attachDiagnostics(page, 'sticky-note-reminder.html');
|
||||
const url = pageUrl('public/sticky-note-reminder.html');
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 25_000 });
|
||||
|
||||
await page.click('#prioSelector .priority-option.high');
|
||||
const priorityActive = await page.evaluate(() =>
|
||||
document.querySelector('#prioSelector .priority-option.high')?.classList.contains('active'),
|
||||
);
|
||||
|
||||
const uniqueTitle = `E2E-${Date.now()}`;
|
||||
const beforeTotal = Number(await page.textContent('#statTotal'));
|
||||
await page.fill('#inputTitle', uniqueTitle);
|
||||
await page.fill('#inputContent', '自动化交互测试内容,用于验证动态 onclick 展开。');
|
||||
await page.click('#btnSubmit');
|
||||
await page.waitForFunction(
|
||||
(title) => [...document.querySelectorAll('.card-title')].some((el) => el.textContent?.includes(title)),
|
||||
uniqueTitle,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
const afterTotal = Number(await page.textContent('#statTotal'));
|
||||
|
||||
await page.click(`.card-title:text("${uniqueTitle}")`);
|
||||
const cardContent = page.locator('.timeline-item', { hasText: uniqueTitle }).locator('.card-content').first();
|
||||
await cardContent.click();
|
||||
await page.waitForTimeout(300);
|
||||
const expanded = await cardContent.evaluate((el) => el.classList.contains('expanded'));
|
||||
|
||||
const headerCsp = await fetch(url).then((res) => res.headers.get('content-security-policy') ?? '');
|
||||
await page.close();
|
||||
|
||||
return {
|
||||
violations,
|
||||
priorityActive,
|
||||
totalIncreased: afterTotal > beforeTotal,
|
||||
expanded,
|
||||
headerCsp,
|
||||
};
|
||||
}
|
||||
|
||||
async function testPlantTreeInteractions(browser) {
|
||||
const page = await browser.newPage();
|
||||
const violations = attachDiagnostics(page, 'plant-tree.html');
|
||||
const url = pageUrl('public/plant-tree.html');
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 25_000 });
|
||||
|
||||
const username = `E2E-${Date.now()}`;
|
||||
await page.fill('#usernameInput', username);
|
||||
await page.click('#plantBtn');
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const toast = document.querySelector('.toast.show, .toast[style*="opacity: 1"]');
|
||||
return toast && /种下/.test(toast.textContent || '');
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
).catch(async () => {
|
||||
await page.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
const toastText = await page.evaluate(() => {
|
||||
const nodes = [...document.querySelectorAll('.toast, #toast')];
|
||||
return nodes.map((node) => node.textContent?.trim()).filter(Boolean).join(' | ');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(name) => [...document.querySelectorAll('.activity-item .name')].some((el) => el.textContent?.includes(name)),
|
||||
username,
|
||||
{ timeout: 10_000 },
|
||||
).catch(() => {});
|
||||
const listed = await page.evaluate(
|
||||
(name) => [...document.querySelectorAll('.activity-item .name')].some((el) => el.textContent?.includes(name)),
|
||||
username,
|
||||
);
|
||||
|
||||
const headerCsp = await fetch(url).then((res) => res.headers.get('content-security-policy') ?? '');
|
||||
await page.close();
|
||||
|
||||
return {
|
||||
violations,
|
||||
toastText,
|
||||
listed,
|
||||
headerCsp,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n=== MindSpace public page interaction verify @ ${base} ===\n`);
|
||||
|
||||
const health = await fetch(`${base}/auth/status`).then((r) => r.ok).catch(() => false);
|
||||
record('portal_running', health, { detail: health ? 'auth/status OK' : 'server down' });
|
||||
if (!health) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFixture('public/csp-onclick-fixture.html', ONCLICK_FIXTURE);
|
||||
await writeFixture('public/csp-listener-fixture.html', ADD_EVENT_LISTENER_FIXTURE);
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await launchBrowser();
|
||||
} catch (error) {
|
||||
record('playwright_available', false, { detail: error?.message || String(error) });
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
record('playwright_available', true, { detail: 'chromium launched' });
|
||||
|
||||
const onclick = await testFixturePage(browser, 'public/csp-onclick-fixture.html', {
|
||||
clickSelector: '#btn',
|
||||
evaluateReady: () => ({
|
||||
bodyClicked: document.body.dataset.clicked === '1',
|
||||
listenerStatus: document.getElementById('status')?.textContent ?? '',
|
||||
}),
|
||||
});
|
||||
record('fixture_onclick_attribute_executes', onclick.ready.bodyClicked, {
|
||||
detail: JSON.stringify(onclick.ready),
|
||||
});
|
||||
record('fixture_onclick_with_listener_both_work', onclick.ready.listenerStatus === 'listener-ok', {
|
||||
detail: onclick.ready.listenerStatus,
|
||||
});
|
||||
record('fixture_onclick_no_csp_violations', onclick.violations.length === 0, {
|
||||
detail: onclick.violations.join(' | ') || 'none',
|
||||
});
|
||||
record('fixture_onclick_csp_allows_inline', /script-src[^;]*'unsafe-inline'/.test(onclick.csp), {
|
||||
detail: onclick.csp.match(/script-src[^;]+/)?.[0] ?? onclick.csp.slice(0, 120),
|
||||
});
|
||||
|
||||
const listener = await testFixturePage(browser, 'public/csp-listener-fixture.html', {
|
||||
clickSelector: '#btn',
|
||||
evaluateReady: () => ({
|
||||
bodyClicked: document.body.dataset.clicked === '1',
|
||||
status: document.getElementById('status')?.textContent ?? '',
|
||||
}),
|
||||
});
|
||||
record('fixture_add_event_listener_executes', listener.ready.status === 'ok', {
|
||||
detail: JSON.stringify(listener.ready),
|
||||
});
|
||||
record('fixture_add_event_listener_no_csp_violations', listener.violations.length === 0, {
|
||||
detail: listener.violations.join(' | ') || 'none',
|
||||
});
|
||||
|
||||
const sticky = await testStickyNoteInteractions(browser);
|
||||
record('sticky_note_priority_click_works', sticky.priorityActive, { detail: String(sticky.priorityActive) });
|
||||
record('sticky_note_submit_increases_total', sticky.totalIncreased, { detail: String(sticky.totalIncreased) });
|
||||
record('sticky_note_dynamic_onclick_expand_works', sticky.expanded, {
|
||||
detail: sticky.expanded ? 'card expanded' : 'onclick expand blocked',
|
||||
});
|
||||
record('sticky_note_no_csp_violations', sticky.violations.length === 0, {
|
||||
detail: sticky.violations.join(' | ') || 'none',
|
||||
});
|
||||
record('sticky_note_csp_allows_inline', /script-src[^;]*'unsafe-inline'/.test(sticky.headerCsp), {
|
||||
detail: sticky.headerCsp.match(/script-src[^;]+/)?.[0] ?? sticky.headerCsp.slice(0, 120),
|
||||
});
|
||||
|
||||
const plant = await testPlantTreeInteractions(browser);
|
||||
record('plant_tree_submit_shows_toast', /种下/.test(plant.toastText), { detail: plant.toastText || 'empty' });
|
||||
record('plant_tree_record_listed', plant.listed, { detail: String(plant.listed) });
|
||||
record('plant_tree_no_csp_violations', plant.violations.length === 0, {
|
||||
detail: plant.violations.join(' | ') || 'none',
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
const ok = checks.every((item) => item.ok);
|
||||
console.log(`\n=== ${ok ? 'ALL PASS — published pages interact correctly under CSP' : 'SOME FAILED'} (${checks.filter((item) => item.ok).length}/${checks.length}) ===\n`);
|
||||
if (!ok) {
|
||||
console.log('Failed checks:');
|
||||
for (const item of checks.filter((entry) => !entry.ok)) {
|
||||
console.log(` - ${item.name}${item.detail ? `: ${item.detail}` : ''}`);
|
||||
}
|
||||
console.log('\nTip: restart dev server (pnpm dev) after CSP policy changes.\n');
|
||||
}
|
||||
process.exitCode = ok ? 0 : 1;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+123
-9
@@ -131,13 +131,18 @@ import {
|
||||
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 { 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';
|
||||
@@ -198,7 +203,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';
|
||||
@@ -734,6 +739,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 ?? [])
|
||||
@@ -741,10 +770,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(
|
||||
@@ -2006,10 +2035,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 {
|
||||
@@ -2017,7 +2050,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: '未授权,请重新登录' });
|
||||
}
|
||||
@@ -3284,9 +3317,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 });
|
||||
}
|
||||
@@ -5075,7 +5115,35 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
||||
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 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 });
|
||||
};
|
||||
// After Finish, refresh the snapshot and persist any newly generated public
|
||||
@@ -5131,7 +5199,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
||||
`[MindSpace] missing public download files after finish for user ${uid}: ${syncResult.docxSync.missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
await maybeRepairH5HtmlAfterFinish({
|
||||
const htmlDelivery = await maybeRepairH5HtmlAfterFinish({
|
||||
sessionId: sid,
|
||||
userId: uid,
|
||||
currentUser: req.currentUser,
|
||||
@@ -5154,7 +5222,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
||||
.join('\n')
|
||||
: '';
|
||||
await syncUserGeneratedPages(uid, { sessionId: sid });
|
||||
await maybeRepairPageDataAfterFinish({
|
||||
const pageDataDelivery = await maybeRepairPageDataAfterFinish({
|
||||
sessionId: sid,
|
||||
userId: uid,
|
||||
publishDir,
|
||||
@@ -5165,6 +5233,40 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
||||
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,
|
||||
@@ -6174,6 +6276,18 @@ async function serveUserPublishFile(req, res, next) {
|
||||
// On-demand cover: rasterize <base>.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)) {
|
||||
|
||||
+16
-12
@@ -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;
|
||||
@@ -2131,6 +2131,10 @@ export async function listNotifications(status = 'unread', limit = 20): Promise<
|
||||
if (status && status !== 'all') params.set('status', status);
|
||||
params.set('limit', String(limit));
|
||||
const response = await fetch(`/auth/notifications?${params}`);
|
||||
if (response.status === 401) {
|
||||
notifyUnauthorized();
|
||||
throw new ApiError(401, '未授权,请重新登录');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, '读取通知失败');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react';
|
||||
import { BrainCircuit, Database } from 'lucide-react';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
import { openAvatarPicker } from '../utils/userAvatar';
|
||||
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
|
||||
@@ -184,6 +185,7 @@ export function ChatPanel({
|
||||
options?: {
|
||||
messageId?: string;
|
||||
forceDeepReasoning?: boolean;
|
||||
pgRequired?: boolean;
|
||||
selectedChatSkill?: string;
|
||||
fileAttachments?: ChatFileAttachment[];
|
||||
},
|
||||
@@ -216,6 +218,8 @@ export function ChatPanel({
|
||||
const [plazaPublishSource, setPlazaPublishSource] = useState<Message | null>(null);
|
||||
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
|
||||
const [forceDeepReasoning, setForceDeepReasoning] = useState(false);
|
||||
const [pgRequired, setPgRequired] = useState(false);
|
||||
const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null);
|
||||
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingChatFile[]>([]);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
@@ -293,6 +297,16 @@ export function ChatPanel({
|
||||
nearBottomRef.current = true;
|
||||
}, [session?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setChatControlOnboardingStep(0);
|
||||
const timers = [
|
||||
window.setTimeout(() => setChatControlOnboardingStep(1), 2_200),
|
||||
window.setTimeout(() => setChatControlOnboardingStep(2), 4_400),
|
||||
window.setTimeout(() => setChatControlOnboardingStep(null), 6_600),
|
||||
];
|
||||
return () => timers.forEach((timer) => window.clearTimeout(timer));
|
||||
}, [session?.id]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = mainRef.current;
|
||||
if (!container) return;
|
||||
@@ -730,6 +744,7 @@ export function ChatPanel({
|
||||
await onSubmit(trimmed, imagesToSend, previewImagesToSend, {
|
||||
messageId: outgoingMessageId,
|
||||
forceDeepReasoning,
|
||||
pgRequired,
|
||||
selectedChatSkill,
|
||||
fileAttachments: fileAttachmentsToSend,
|
||||
});
|
||||
@@ -758,6 +773,7 @@ export function ChatPanel({
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
}, [
|
||||
forceDeepReasoning,
|
||||
pgRequired,
|
||||
input,
|
||||
onSubmit,
|
||||
onUploadFile,
|
||||
@@ -1189,6 +1205,7 @@ export function ChatPanel({
|
||||
<ChatSkillPicker
|
||||
skills={chatSkills}
|
||||
disabled={voiceDisabled}
|
||||
onboardingActive={chatControlOnboardingStep === 0}
|
||||
onSelect={submitText}
|
||||
onPrefill={(prompt, skillId) => {
|
||||
pendingSkillRef.current = skillId ?? null;
|
||||
@@ -1196,23 +1213,38 @@ export function ChatPanel({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!showHomeWelcome && (
|
||||
<label
|
||||
className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}`}
|
||||
title="勾选后,本轮消息使用深度推理完成"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceDeepReasoning}
|
||||
disabled={voiceDisabled}
|
||||
onChange={(event) => setForceDeepReasoning(event.target.checked)}
|
||||
/>
|
||||
<span className="chat-deep-reasoning-toggle-label" aria-hidden="true">
|
||||
<span>深度</span>
|
||||
<span>推理</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<label
|
||||
className={`chat-deep-reasoning-toggle${pgRequired ? ' is-active' : ''}${chatControlOnboardingStep === 1 ? ' chat-control-onboarding-active' : ''}`}
|
||||
title="勾选后,本轮生成的可保存数据必须使用你的专属 PostgreSQL 数据空间"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pgRequired}
|
||||
disabled={voiceDisabled}
|
||||
aria-label="使用 PostgreSQL 数据空间"
|
||||
onChange={(event) => setPgRequired(event.target.checked)}
|
||||
/>
|
||||
<Database className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
|
||||
{chatControlOnboardingStep === 1 && (
|
||||
<span className="chat-control-onboarding-tip" role="status">需要长期使用的数据,放进专属空间</span>
|
||||
)}
|
||||
</label>
|
||||
<label
|
||||
className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}${chatControlOnboardingStep === 2 ? ' chat-control-onboarding-active' : ''}`}
|
||||
title="勾选后,本轮消息使用深度推理完成"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceDeepReasoning}
|
||||
disabled={voiceDisabled}
|
||||
aria-label="使用深度推理"
|
||||
onChange={(event) => setForceDeepReasoning(event.target.checked)}
|
||||
/>
|
||||
<BrainCircuit className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
|
||||
{chatControlOnboardingStep === 2 && (
|
||||
<span className="chat-control-onboarding-tip" role="status">复杂问题,开启深度推理</span>
|
||||
)}
|
||||
</label>
|
||||
{chatState === 'streaming' ? (
|
||||
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
|
||||
停止
|
||||
|
||||
@@ -5,11 +5,13 @@ import { ChatSkillIcon } from './ChatSkillIcons';
|
||||
export function ChatSkillPicker({
|
||||
skills,
|
||||
disabled,
|
||||
onboardingActive = false,
|
||||
onSelect,
|
||||
onPrefill,
|
||||
}: {
|
||||
skills: ChatSkillOption[];
|
||||
disabled?: boolean;
|
||||
onboardingActive?: boolean;
|
||||
onSelect: (prompt: string, skillId?: string) => void | Promise<void>;
|
||||
onPrefill?: (prompt: string, skillId?: string) => void;
|
||||
}) {
|
||||
@@ -50,7 +52,7 @@ export function ChatSkillPicker({
|
||||
<div className={`chat-skill-picker${open ? ' open' : ''}`} ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-skill-trigger"
|
||||
className={`chat-skill-trigger${onboardingActive ? ' chat-control-onboarding-active' : ''}`}
|
||||
disabled={disabled}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
@@ -59,6 +61,9 @@ export function ChatSkillPicker({
|
||||
>
|
||||
<ChatSkillIcon id="spark" className="chat-skill-trigger-icon" />
|
||||
<span className="chat-skill-trigger-label">skill</span>
|
||||
{onboardingActive && (
|
||||
<span className="chat-control-onboarding-tip" role="status">点击 Skill,更快捷完成任务</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
|
||||
@@ -547,6 +547,7 @@ export function ChatView({
|
||||
{
|
||||
messageId: options?.messageId,
|
||||
forceDeepReasoning: options?.forceDeepReasoning,
|
||||
pgRequired: options?.pgRequired,
|
||||
selectedChatSkill: options?.selectedChatSkill,
|
||||
fileAttachments: options?.fileAttachments,
|
||||
},
|
||||
|
||||
@@ -147,6 +147,7 @@ export function SpaceChatPanel({
|
||||
...context,
|
||||
messageId: options?.messageId,
|
||||
forceDeepReasoning: options?.forceDeepReasoning,
|
||||
pgRequired: options?.pgRequired,
|
||||
fileAttachments: options?.fileAttachments,
|
||||
},
|
||||
imageUrls,
|
||||
@@ -158,6 +159,7 @@ export function SpaceChatPanel({
|
||||
mindspaceContext: context,
|
||||
messageId: options?.messageId,
|
||||
forceDeepReasoning: options?.forceDeepReasoning,
|
||||
pgRequired: options?.pgRequired,
|
||||
fileAttachments: options?.fileAttachments,
|
||||
},
|
||||
imageUrls,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -279,6 +275,10 @@ function isTransientConnectError(err: unknown) {
|
||||
return /超时|timeout/i.test(err.message);
|
||||
}
|
||||
|
||||
function isMissingAgentSessionError(err: unknown) {
|
||||
return err instanceof ApiError && /session not found/i.test(err.message);
|
||||
}
|
||||
|
||||
async function withTransientConnectRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
@@ -329,6 +329,7 @@ export function useTKMindChat(
|
||||
const subscribedSessionIdRef = useRef<string | null>(null);
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
const connectTokenRef = useRef(0);
|
||||
const unavailableAgentSessionIdsRef = useRef(new Set<string>());
|
||||
const messagesRef = useRef<Message[]>([]);
|
||||
const sessionRef = useRef<Session | null>(null);
|
||||
const sessionsRef = useRef<SessionSummary[]>([]);
|
||||
@@ -1139,7 +1140,9 @@ export function useTKMindChat(
|
||||
}),
|
||||
);
|
||||
const resumedPromise =
|
||||
options?.skipResume || isDirectChatSessionId(sessionId)
|
||||
options?.skipResume ||
|
||||
isDirectChatSessionId(sessionId) ||
|
||||
unavailableAgentSessionIdsRef.current.has(sessionId)
|
||||
? Promise.resolve(options.seedSession ?? null)
|
||||
: withTransientConnectRetry(() =>
|
||||
resumeSession(sessionId, {
|
||||
@@ -1160,7 +1163,16 @@ export function useTKMindChat(
|
||||
setMessageHistoryHasMore(messageHistoryHasMoreRef.current);
|
||||
setMessageHistoryTotal(messageHistoryTotalRef.current);
|
||||
setMessages(history);
|
||||
const resumed = await resumedPromise;
|
||||
let resumed: Session | null = null;
|
||||
try {
|
||||
resumed = await resumedPromise;
|
||||
} catch (err) {
|
||||
if (!isMissingAgentSessionError(err)) throw err;
|
||||
// Persisted conversation is still readable even when its transient
|
||||
// Agent runtime has been reclaimed. Do not leave a completed chat
|
||||
// in the connecting state or retry resume on future selections.
|
||||
unavailableAgentSessionIdsRef.current.add(sessionId);
|
||||
}
|
||||
if (token !== connectTokenRef.current) return;
|
||||
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
|
||||
setChatState('idle');
|
||||
@@ -1383,6 +1395,7 @@ export function useTKMindChat(
|
||||
mindspaceContext?: MindSpaceChatContext;
|
||||
messageId?: string;
|
||||
forceDeepReasoning?: boolean;
|
||||
pgRequired?: boolean;
|
||||
selectedChatSkill?: string;
|
||||
fileAttachments?: ChatFileAttachment[];
|
||||
},
|
||||
@@ -1412,7 +1425,10 @@ export function useTKMindChat(
|
||||
: '';
|
||||
const userPrefix = buildUserAddressPrefix(userRef.current);
|
||||
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
|
||||
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}`;
|
||||
const pgContractPrefix = options?.pgRequired
|
||||
? '[交付约束:用户已明确要求使用专属 PostgreSQL 数据空间。若生成页面,必须按 page-data-collect 完成建表、dataset、policy 和 workspace page 绑定;禁止 localStorage、SQLite、静态 JSON 或内存持久化。所有验证通过前不得回复已发布或给出页面链接。]\n'
|
||||
: '';
|
||||
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}${pgContractPrefix}`;
|
||||
const priorMessageCount = messagesRef.current.length;
|
||||
const userMessage = buildUserMessage(trimmed, {
|
||||
id: options?.messageId,
|
||||
@@ -1429,6 +1445,7 @@ export function useTKMindChat(
|
||||
? userMessage.metadata.memindRun
|
||||
: {}),
|
||||
sessionMessageCount: priorMessageCount,
|
||||
...(options?.pgRequired ? { pgRequired: true } : {}),
|
||||
...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}),
|
||||
},
|
||||
};
|
||||
@@ -1582,6 +1599,11 @@ export function useTKMindChat(
|
||||
const nextChatState = resolvePostAgentRunChatState({
|
||||
chatState: chatStateRef.current,
|
||||
finishedViaPortalDirectChat,
|
||||
// The agent-run result is authoritative even when the immediate
|
||||
// session snapshot has not yet carried portal-direct metadata.
|
||||
// Without this, a completed Page Data task can re-enter streaming
|
||||
// and leave the Stop button attached to no active request.
|
||||
agentRunSucceeded: finishedRun.status === 'succeeded',
|
||||
});
|
||||
if (nextChatState === 'idle') {
|
||||
clearActiveRequestMissingTimer();
|
||||
@@ -1593,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);
|
||||
|
||||
+86
-11
@@ -717,20 +717,25 @@ body,
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 2px 4px;
|
||||
height: 24px;
|
||||
padding: 2px 3px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
flex-direction: row;
|
||||
gap: 3px;
|
||||
gap: 2px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-deep-reasoning-toggle input {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-deep-reasoning-toggle-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-deep-reasoning-toggle-label {
|
||||
@@ -1843,7 +1848,7 @@ body,
|
||||
.page-save-preview-hint {
|
||||
margin: 0;
|
||||
color: #7a8680;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -3061,6 +3066,7 @@ body,
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
@@ -3101,6 +3107,70 @@ body,
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-control-onboarding-active {
|
||||
position: relative;
|
||||
z-index: 31;
|
||||
animation: chat-control-onboarding-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chat-control-onboarding-tip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 7px);
|
||||
left: 50%;
|
||||
z-index: 2;
|
||||
width: max-content;
|
||||
max-width: min(165px, calc(100vw - 20px));
|
||||
padding: 4px 6px;
|
||||
border: 1px solid rgba(218, 229, 255, 0.6);
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, rgba(73, 117, 239, 0.98), rgba(152, 91, 230, 0.98));
|
||||
box-shadow: 0 6px 14px rgba(103, 89, 221, 0.28), 0 1px 3px rgba(28, 38, 100, 0.2);
|
||||
color: #fff;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0.01em;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
animation: chat-control-onboarding-tip-in 220ms ease-out both;
|
||||
}
|
||||
|
||||
.chat-control-onboarding-tip::after {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 1px solid rgba(218, 229, 255, 0.6);
|
||||
border-bottom: 1px solid rgba(218, 229, 255, 0.6);
|
||||
background: #835fdb;
|
||||
content: '';
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
@keyframes chat-control-onboarding-pulse {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-2px) scale(1.07); }
|
||||
}
|
||||
|
||||
@keyframes chat-control-onboarding-tip-in {
|
||||
from { opacity: 0; transform: translate(-50%, 4px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-control-onboarding-active,
|
||||
.chat-control-onboarding-tip {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle.is-active {
|
||||
color: #eff6ff;
|
||||
border-color: rgba(121, 183, 255, 0.6);
|
||||
@@ -9938,12 +10008,12 @@ body,
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 2px 4px;
|
||||
height: 24px;
|
||||
padding: 2px 3px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
flex-direction: row;
|
||||
gap: 3px;
|
||||
gap: 2px;
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
}
|
||||
@@ -9953,8 +10023,13 @@ body,
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle input {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle-label {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user