fix(page-data): harden structural delivery and Portal base URL

Reject /api/page-data/ legacy passthrough, verify live API before send,
and resolve delivery links to Portal (8081) instead of Vite (5173).
Add local repair/verify scripts for daily-register Page Data flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-13 09:35:40 +08:00
parent 249b0697cb
commit 804f13bc04
11 changed files with 712 additions and 51 deletions
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env node
/**
* 修复 john 用户「每日待办登记」Page Data 交付:
* - 前台页改为 public + insertRow
* - 后台页 password + listRows/updateRow/deleteRow
*
* Usage: node scripts/repair-daily-todo-local.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
import { readPageAccessPolicy, writePageAccessPolicy } from '../page-data-policy-store.mjs';
import { resolvePageDataDeliveryBaseUrl } from '../user-publish.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '.env'));
loadEnvFile(path.join(root, '../../.env.local'));
const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32';
const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID);
const ADMIN_PASSWORD = 'adminadmin';
const DATASET = 'daily_todo';
const LOCAL_BASE = process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL || resolvePageDataDeliveryBaseUrl();
const FORM_POLICY = {
accessMode: 'public',
datasets: {
[DATASET]: {
insert: true,
read: false,
columns: {
insert: ['name', 'date', 'todo_items', 'priority', 'status', 'remark'],
},
},
},
};
const ADMIN_POLICY = {
accessMode: 'password',
datasets: {
[DATASET]: {
insert: false,
read: true,
update: true,
softDelete: true,
columns: {
read: ['id', 'name', 'date', 'todo_items', 'priority', 'status', 'remark', 'created_at', 'updated_at'],
update: ['name', 'date', 'todo_items', 'priority', 'status', 'remark', 'updated_at'],
soft_delete: ['id'],
},
},
},
};
async function bindPages(pool, h5Root, storageRoot) {
const pages = [
{
relativePath: 'public/daily-todo.html',
accessMode: 'public',
password: null,
policy: FORM_POLICY,
},
{
relativePath: 'public/daily-todo-admin.html',
accessMode: 'password',
password: ADMIN_PASSWORD,
policy: ADMIN_POLICY,
},
];
const bound = [];
for (const page of pages) {
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId: JOHN_USER_ID,
workspaceRoot: WORKSPACE_ROOT,
relativePath: page.relativePath,
accessMode: page.accessMode,
password: page.password,
pageDataPolicy: page.policy,
});
bound.push({
relativePath: page.relativePath,
pageId: result.pageId,
workspaceUrl: result.workspaceUrl,
publicationAccessMode: result.publicationAccessMode,
});
}
return bound;
}
function patchAdminPolicySoftDelete(pageId) {
const policy = readPageAccessPolicy(WORKSPACE_ROOT, pageId);
if (!policy?.datasets?.[DATASET]) return policy;
policy.datasets[DATASET].update = true;
policy.datasets[DATASET].softDelete = true;
policy.datasets[DATASET].hardDelete = false;
policy.datasets[DATASET].columns.update = ADMIN_POLICY.datasets[DATASET].columns.update;
return writePageAccessPolicy(WORKSPACE_ROOT, policy);
}
async function smokeInsert(pageId) {
const res = await fetch(`${LOCAL_BASE}/api/public/pages/${pageId}/data/${DATASET}/rows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
name: '本地验证',
date: '2026-07-13',
todo_items: '测试待办',
priority: '普通',
status: '待完成',
remark: 'repair script',
}),
});
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
async function smokeAdminList(pageId) {
const authRes = await fetch(`${LOCAL_BASE}/api/public/pages/${pageId}/data-auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ password: ADMIN_PASSWORD }),
});
const authBody = await authRes.json().catch(() => ({}));
const token = authBody?.data?.token;
if (!token) return { status: authRes.status, body: authBody, listStatus: 0 };
const listRes = await fetch(`${LOCAL_BASE}/api/public/pages/${pageId}/data/${DATASET}?limit=5`, {
headers: { Accept: 'application/json', 'x-page-data-token': token },
});
const listBody = await listRes.json().catch(() => ({}));
return { status: authRes.status, listStatus: listRes.status, rows: listBody?.data?.rows?.length ?? 0 };
}
const pool = createDbPool();
const h5Root = root;
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
console.log('=== 每日待办登记 · 本地修复 ===\n');
const bound = await bindPages(pool, h5Root, storageRoot);
console.log('✔ 页面已重新绑定\n');
for (const item of bound) {
console.log(` ${item.relativePath}`);
console.log(` pageId: ${item.pageId}`);
console.log(` access: ${item.publicationAccessMode}`);
console.log(` url: ${item.workspaceUrl}\n`);
}
const adminPageId = bound.find((item) => item.relativePath === 'public/daily-todo-admin.html')?.pageId;
if (adminPageId) {
patchAdminPolicySoftDelete(adminPageId);
console.log('✔ 后台策略已启用 update + softDelete\n');
}
const registerPageId = bound.find((item) => item.relativePath === 'public/daily-todo.html')?.pageId;
const insert = registerPageId ? await smokeInsert(registerPageId) : { status: 0 };
const admin = adminPageId ? await smokeAdminList(adminPageId) : { listStatus: 0 };
console.log('【insert 冒烟】', JSON.stringify(insert, null, 2));
console.log('【admin 冒烟】', JSON.stringify(admin, null, 2));
console.log('\n=== 正确访问链接(Portal 8081===');
for (const item of bound) {
console.log(` ${item.relativePath}: ${item.workspaceUrl}`);
}
console.log(` 后台管理密码: ${ADMIN_PASSWORD}`);
console.log('\n注意:Agent 交付链接必须用 Portal8081),不要用 Vite UI5173)。');
const passed = insert.status === 201 && admin.listStatus === 200;
console.log(`\n${passed ? '✔ 修复完成,链路可用' : '✘ 仍有问题,请检查上方输出'}`);
await pool.end();
process.exit(passed ? 0 : 1);
+315
View File
@@ -0,0 +1,315 @@
#!/usr/bin/env node
/**
* 本地验证「每日登记事项」Page Data 交付链路:
* 1. 绑定前守卫应拦截(与生产 WeChat 一致)
* 2. 补 dataset + bind 后守卫应放行
* 3. insert API 冒烟
*
* Usage: node scripts/verify-daily-register-local.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
import { ensureRegisteredDatasetFromHtml } from '../page-data-workspace-ensure.mjs';
import {
resolvePageDataCollectOutcomeAsync,
evaluatePageDataFinishGuardAsync,
} from '../mindspace-page-data-finish-guard.mjs';
import { createPageService } from '../mindspace-pages.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '.env'));
loadEnvFile(path.join(root, '../../.env.local'));
const USER_MSG =
'帮我做一个"每日登记事项":填写姓名、年龄、最喜欢的阅读类型;需要一个填写页面和一个后台查看页面。页面做好后请把两个链接都发给我。';
const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32';
const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID);
const ADMIN_PASSWORD = '88888888';
const DATASET = 'daily_register';
const LOCAL_BASE = process.env.MEMIND_PUBLIC_BASE_URL || 'http://127.0.0.1:8081';
const PROD_OWNER = 'a70ff537-8908-486e-9b6c-042e07cc25db';
const TOOL_MESSAGES = [
{
content: [{
type: 'toolRequest',
toolCall: { value: { name: 'sandbox-fs__write_file', arguments: { path: 'public/daily-register.html' } } },
}],
},
{
content: [{
type: 'toolRequest',
toolCall: { value: { name: 'sandbox-fs__write_file', arguments: { path: 'public/daily-admin.html' } } },
}],
},
];
function patchAdminHtmlForPageData(html) {
if (/listRows\s*\(\s*['"]daily_register['"]/.test(html)) return html;
const scriptStart = html.indexOf('<script>\n const PASSWORD');
const scriptEnd = html.indexOf('</script>', scriptStart);
if (scriptStart < 0 || scriptEnd < 0) {
throw new Error('daily-admin.html 结构不符合预期,无法注入 Page Data 脚本');
}
const replacement = `<script src="/assets/page-data-client.js"></script>
<script>
const PASSWORD = '${ADMIN_PASSWORD}';
var client = MindSpacePageData.createClient({ apiBase: '/api' });
function loadRecords() {
return client.listRows('daily_register');
}
function login() {
const pwd = document.getElementById('passwordInput').value;
if (pwd === PASSWORD) {
document.getElementById('loginSection').style.display = 'none';
document.getElementById('adminSection').style.display = 'block';
document.getElementById('loginError').style.display = 'none';
renderRecords();
sessionStorage.setItem('admin_logged_in', 'true');
} else {
document.getElementById('loginError').style.display = 'block';
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
}
}
function logout() {
document.getElementById('loginSection').style.display = 'block';
document.getElementById('adminSection').style.display = 'none';
document.getElementById('passwordInput').value = '';
sessionStorage.removeItem('admin_logged_in');
}
function renderRecords() {
loadRecords().then(function(records) {
const tbody = document.getElementById('recordsBody');
const noData = document.getElementById('noData');
const totalCount = document.getElementById('totalCount');
totalCount.textContent = records.length;
if (!records.length) {
tbody.innerHTML = '';
noData.style.display = 'block';
return;
}
noData.style.display = 'none';
tbody.innerHTML = records.map(function(r, i) {
var idx = records.length - i;
var type = r.reading_type || r.readingType || '';
return '<tr><td>' + idx + '</td><td><strong>' + escapeHtml(r.name || '') + '</strong></td><td>' + (r.age || '') + '</td><td>' + escapeHtml(type) + '</td><td>' + escapeHtml(r.created_at || r.createdAt || '') + '</td></tr>';
}).join('');
}).catch(function(err) {
alert('加载失败:' + (err.message || err));
});
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function clearAll() {
alert('后台暂不支持批量删除,请在数据库或管理工具中清理。');
}
(function() {
if (sessionStorage.getItem('admin_logged_in') === 'true') {
document.getElementById('loginSection').style.display = 'none';
document.getElementById('adminSection').style.display = 'block';
renderRecords();
}
})();`;
return `${html.slice(0, scriptStart)}${replacement}${html.slice(scriptEnd + '</script>'.length)}`;
}
async function ensureProdHtmlLocally() {
const publicDir = path.join(WORKSPACE_ROOT, 'public');
fs.mkdirSync(publicDir, { recursive: true });
for (const name of ['daily-register.html', 'daily-admin.html']) {
const target = path.join(publicDir, name);
const url = `https://m.tkmind.cn/MindSpace/${PROD_OWNER}/public/${name}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`拉取生产 HTML 失败 ${name}: ${res.status}`);
let html = await res.text();
if (name === 'daily-admin.html') html = patchAdminHtmlForPageData(html);
fs.writeFileSync(target, html, 'utf8');
}
}
async function assessGuard(pool, pageService, label) {
const guard = await evaluatePageDataFinishGuardAsync({
publishDir: WORKSPACE_ROOT,
agentText: USER_MSG,
messages: TOOL_MESSAGES,
pool,
userId: JOHN_USER_ID,
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
});
const outcome = await resolvePageDataCollectOutcomeAsync({
reply: { text: '问卷和后台已创建,可以提交。', messages: TOOL_MESSAGES },
intent: { agentText: USER_MSG },
publishDir: WORKSPACE_ROOT,
pool,
userId: JOHN_USER_ID,
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
apiBase: LOCAL_BASE,
});
return {
label,
needsRepair: guard.needsRepair,
unboundFiles: guard.unboundFiles.map((file) => file.relativePath),
htmlIssues: guard.htmlIssues,
outcomeAction: outcome.action,
outcomeReason: outcome.reason,
};
}
async function bindPages(pool, h5Root, storageRoot) {
const registerHtml = fs.readFileSync(path.join(WORKSPACE_ROOT, 'public/daily-register.html'), 'utf8');
await ensureRegisteredDatasetFromHtml({
workspaceRoot: WORKSPACE_ROOT,
userId: JOHN_USER_ID,
html: registerHtml,
datasetName: DATASET,
});
const pages = [
{
relativePath: 'public/daily-register.html',
accessMode: 'public',
password: null,
policy: {
accessMode: 'public',
datasets: {
[DATASET]: {
insert: true,
read: false,
columns: { insert: ['name', 'age', 'reading_type'] },
},
},
},
},
{
relativePath: 'public/daily-admin.html',
accessMode: 'password',
password: ADMIN_PASSWORD,
policy: {
accessMode: 'password',
datasets: {
[DATASET]: {
insert: false,
read: true,
columns: { read: ['id', 'name', 'age', 'reading_type', 'created_at'] },
},
},
},
},
];
const bound = [];
for (const page of pages) {
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId: JOHN_USER_ID,
workspaceRoot: WORKSPACE_ROOT,
relativePath: page.relativePath,
accessMode: page.accessMode,
password: page.password,
pageDataPolicy: page.policy,
});
bound.push({
relativePath: page.relativePath,
pageId: result.pageId,
workspaceUrl: result.workspaceUrl,
publicationUrl: result.publicationUrl,
});
}
return bound;
}
async function smokeInsert(pageId) {
const res = await fetch(`${LOCAL_BASE}/api/public/pages/${pageId}/data/${DATASET}/rows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
name: '本地验证',
age: 28,
reading_type: '科学技术',
}),
});
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
const pool = createDbPool();
const h5Root = root;
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
const pageService = createPageService(pool, { h5Root, storageRoot });
console.log('=== 每日登记事项 · 本地验证 ===\n');
await ensureProdHtmlLocally();
console.log('✔ 已同步生产 HTML 到本地 john 工作区(后台页已改为 Page Data listRows\n');
const before = await assessGuard(pool, pageService, '绑定前');
console.log('【绑定前】', JSON.stringify(before, null, 2));
const bound = await bindPages(pool, h5Root, storageRoot);
console.log('\n✔ 绑定完成');
for (const item of bound) {
console.log(` ${item.relativePath}`);
console.log(` pageId: ${item.pageId}`);
console.log(` url: ${item.workspaceUrl}`);
}
const after = await assessGuard(pool, pageService, '绑定后');
console.log('\n【绑定后】', JSON.stringify(after, null, 2));
const registerPageId = bound.find((item) => item.relativePath === 'public/daily-register.html')?.pageId;
const insert = registerPageId ? await smokeInsert(registerPageId) : { status: 0, body: { error: 'missing pageId' } };
console.log('\n【insert 冒烟】', JSON.stringify(insert, null, 2));
const passed =
before.outcomeAction !== 'send' &&
after.outcomeAction !== 'fail' &&
insert.status === 201;
console.log('\n=== 补充:Agent 未 load page-data-collect 技能时 ===');
console.log('绑定完成后守卫仍可能报 incomplete_delivery(要求 Agent 走正式技能链路)');
console.log('若消息含 load_skill(page-data-collect) + bind 工具,守卫 outcome = send');
console.log('\n=== 本地可访问链接(已 bind ===');
for (const item of bound) {
console.log(` ${item.relativePath}: ${item.workspaceUrl.replace('5173', '8081')}`);
}
console.log(` 后台密码: ${ADMIN_PASSWORD}`);
console.log('\n=== 汇总 ===');
console.log(`绑定前守卫: ${before.outcomeAction} (${before.outcomeReason ?? 'n/a'})`);
console.log(`绑定后守卫: ${after.outcomeAction} (${after.outcomeReason ?? 'n/a'})`);
console.log(`insert API: HTTP ${insert.status}`);
console.log(passed ? '✔ 本地验证通过:问题可复现且修复后交付完整' : '✘ 本地验证未完全通过,请检查上方输出');
await pool.end();
process.exit(passed ? 0 : 1);