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:
@@ -8,6 +8,8 @@
|
|||||||
TKMIND_API_TARGET=https://127.0.0.1:18006
|
TKMIND_API_TARGET=https://127.0.0.1:18006
|
||||||
TKMIND_SERVER__SECRET_KEY=local-dev-secret
|
TKMIND_SERVER__SECRET_KEY=local-dev-secret
|
||||||
H5_PORT=8081
|
H5_PORT=8081
|
||||||
|
# Vite UI 预览用 5173;Agent 交付的 MindSpace 公开页 + Page Data API 走 Portal(8081)。
|
||||||
|
# pnpm dev / dev-core 在未显式覆盖时会把 H5_PUBLIC_BASE_URL 设为 8081。
|
||||||
H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
||||||
|
|
||||||
# 生产 H5 public base 当前临时切到 https://mm.tkmind.cn。
|
# 生产 H5 public base 当前临时切到 https://mm.tkmind.cn。
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|||||||
import { isPageDataIntent } from './chat-skills.mjs';
|
import { isPageDataIntent } from './chat-skills.mjs';
|
||||||
import {
|
import {
|
||||||
detectPageDataDatasetUsageFromHtml,
|
detectPageDataDatasetUsageFromHtml,
|
||||||
|
htmlUsesForbiddenLegacyPageDataApi,
|
||||||
htmlUsesPageDataApi,
|
htmlUsesPageDataApi,
|
||||||
inferPageDataBindAccessMode,
|
inferPageDataBindAccessMode,
|
||||||
} from './page-data-html-detect.mjs';
|
} from './page-data-html-detect.mjs';
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
normalizePageDataApiBase,
|
normalizePageDataApiBase,
|
||||||
verifyPageDataDeliveryArtifacts,
|
verifyPageDataDeliveryArtifacts,
|
||||||
} from './page-data-delivery-assess.mjs';
|
} from './page-data-delivery-assess.mjs';
|
||||||
import { buildPublicUrl, resolvePublicBaseUrl } from './user-publish.mjs';
|
import { buildPublicUrl, resolvePageDataDeliveryBaseUrl } from './user-publish.mjs';
|
||||||
|
|
||||||
export { inferPageDataBindAccessMode };
|
export { inferPageDataBindAccessMode };
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ function readPublicHtmlFiles(publishDir) {
|
|||||||
|
|
||||||
export function collectPageDataDeliveryArtifacts(
|
export function collectPageDataDeliveryArtifacts(
|
||||||
publishDir,
|
publishDir,
|
||||||
{ publicBaseUrl = resolvePublicBaseUrl() } = {},
|
{ publicBaseUrl = resolvePageDataDeliveryBaseUrl() } = {},
|
||||||
) {
|
) {
|
||||||
const ownerKey = path.basename(path.resolve(String(publishDir ?? '')));
|
const ownerKey = path.basename(path.resolve(String(publishDir ?? '')));
|
||||||
if (!ownerKey) return [];
|
if (!ownerKey) return [];
|
||||||
@@ -112,13 +113,17 @@ export function evaluatePageDataHtmlContent(html, { relativePath = '' } = {}) {
|
|||||||
const usesPageDataApi =
|
const usesPageDataApi =
|
||||||
usage.size > 0 ||
|
usage.size > 0 ||
|
||||||
PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) ||
|
PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) ||
|
||||||
/\bMindSpacePageData\b/.test(content);
|
/\bMindSpacePageData\b/.test(content) ||
|
||||||
|
htmlUsesForbiddenLegacyPageDataApi(content);
|
||||||
|
|
||||||
if (!usesPageDataApi) {
|
if (!usesPageDataApi) {
|
||||||
return { usesPageDataApi: false, issues: [] };
|
return { usesPageDataApi: false, issues: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const issues = [];
|
const issues = [];
|
||||||
|
if (htmlUsesForbiddenLegacyPageDataApi(content)) {
|
||||||
|
issues.push('forbidden_legacy_page_data_api');
|
||||||
|
}
|
||||||
if (!PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content)) {
|
if (!PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content)) {
|
||||||
issues.push('missing_page_data_client_script');
|
issues.push('missing_page_data_client_script');
|
||||||
}
|
}
|
||||||
@@ -382,7 +387,7 @@ export function buildPageDataCollectFailureText() {
|
|||||||
'这次问卷/数据收集页面没有完成 Page Data API 绑定,所以我先不发链接。',
|
'这次问卷/数据收集页面没有完成 Page Data API 绑定,所以我先不发链接。',
|
||||||
'请直接重发一次完整需求(例如:调查问卷 + 后台查看),我会按 page-data-collect 技能:',
|
'请直接重发一次完整需求(例如:调查问卷 + 后台查看),我会按 page-data-collect 技能:',
|
||||||
'建表 → 注册 dataset → 写含 page-data-client.js 的 HTML → private_data_bind_workspace_page 发布。',
|
'建表 → 注册 dataset → 写含 page-data-client.js 的 HTML → private_data_bind_workspace_page 发布。',
|
||||||
'数据必须走平台 API 写入 SQLite,禁止 localStorage 或自建后端。',
|
'数据必须走平台 API(MindSpacePageData.createClient + /api/public/pages/...),禁止 /api/page-data/ 旁路、localStorage 或自建后端。',
|
||||||
].join('');
|
].join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,8 +399,9 @@ export function buildPageDataCollectRepairPrompt({
|
|||||||
'【系统补绑请求】检测到 Page Data 问卷/数据页交付不完整。请立即按 page-data-collect 技能修复:',
|
'【系统补绑请求】检测到 Page Data 问卷/数据页交付不完整。请立即按 page-data-collect 技能修复:',
|
||||||
'1. load_skill → page-data-collect',
|
'1. load_skill → page-data-collect',
|
||||||
'2. 确保 public/*.html 引入 /assets/page-data-client.js,且 JS 使用 MindSpacePageData.createClient({ apiBase: "/api" })',
|
'2. 确保 public/*.html 引入 /assets/page-data-client.js,且 JS 使用 MindSpacePageData.createClient({ apiBase: "/api" })',
|
||||||
'3. 禁止 localStorage / 浏览器本地存储 fallback',
|
'3. 禁止 /api/page-data/ 旁路、localStorage 或自建后端',
|
||||||
'4. 对每个 public/*.html 调用 private_data_bind_workspace_page(问卷页 public insert,后台页 password read,口令默认 88888888)',
|
'4. 对每个 public/*.html 调用 private_data_bind_workspace_page(问卷页 public insert,后台页 password read,口令默认 88888888)',
|
||||||
|
'5. 向用户交付的链接必须使用 Portal(本地 http://127.0.0.1:8081),不要用 Vite 5173',
|
||||||
];
|
];
|
||||||
if (htmlIssues.length) {
|
if (htmlIssues.length) {
|
||||||
lines.push('', 'HTML 问题:');
|
lines.push('', 'HTML 问题:');
|
||||||
@@ -507,13 +513,16 @@ export async function resolvePageDataCollectOutcomeAsync({
|
|||||||
return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation };
|
return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evaluation.unboundFiles.length > 0 && pool && userId && apiBase) {
|
const deliveryArtifacts =
|
||||||
const artifacts = collectPageDataDeliveryArtifacts(publishDir).filter((artifact) =>
|
evaluation.relevantFiles.length > 0
|
||||||
|
? collectPageDataDeliveryArtifacts(publishDir).filter((artifact) =>
|
||||||
evaluation.relevantFiles.some((file) => file.relativePath === artifact.relativePath),
|
evaluation.relevantFiles.some((file) => file.relativePath === artifact.relativePath),
|
||||||
);
|
)
|
||||||
if (artifacts.length > 0) {
|
: [];
|
||||||
const failures = await verifyPageDataDeliveryArtifacts({
|
|
||||||
artifacts,
|
if (pool && userId && apiBase && deliveryArtifacts.length > 0) {
|
||||||
|
const deliveryFailures = await verifyPageDataDeliveryArtifacts({
|
||||||
|
artifacts: deliveryArtifacts,
|
||||||
publishDir,
|
publishDir,
|
||||||
apiBase,
|
apiBase,
|
||||||
pool,
|
pool,
|
||||||
@@ -521,7 +530,7 @@ export async function resolvePageDataCollectOutcomeAsync({
|
|||||||
findPageByRelativePath,
|
findPageByRelativePath,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
});
|
});
|
||||||
if (failures.length === 0) {
|
if (deliveryFailures.length === 0) {
|
||||||
const reassessment = await evaluatePageDataFinishGuardAsync({
|
const reassessment = await evaluatePageDataFinishGuardAsync({
|
||||||
publishDir,
|
publishDir,
|
||||||
agentText,
|
agentText,
|
||||||
@@ -534,7 +543,20 @@ export async function resolvePageDataCollectOutcomeAsync({
|
|||||||
if (reassessment.unboundFiles.length === 0 && reassessment.htmlIssues.length === 0) {
|
if (reassessment.unboundFiles.length === 0 && reassessment.htmlIssues.length === 0) {
|
||||||
return { action: 'send', reason: 'verified_by_live_api', evaluation: reassessment };
|
return { action: 'send', reason: 'verified_by_live_api', evaluation: reassessment };
|
||||||
}
|
}
|
||||||
}
|
} else if (evaluation.unboundFiles.length > 0) {
|
||||||
|
return {
|
||||||
|
action: 'retry',
|
||||||
|
reason: 'missing_bind',
|
||||||
|
evaluation,
|
||||||
|
deliveryFailures,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
action: 'retry',
|
||||||
|
reason: 'delivery_smoke_failed',
|
||||||
|
evaluation,
|
||||||
|
deliveryFailures,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ async function save(data) {
|
|||||||
}
|
}
|
||||||
</script></body></html>`;
|
</script></body></html>`;
|
||||||
|
|
||||||
|
const LEGACY_API_HTML = `<!doctype html><html><body><script>
|
||||||
|
fetch('/api/page-data/daily_todo', { method: 'POST', body: JSON.stringify({ name: 'test' }) });
|
||||||
|
</script></body></html>`;
|
||||||
|
|
||||||
test('evaluatePageDataHtmlContent flags missing client script and localStorage fallback', () => {
|
test('evaluatePageDataHtmlContent flags missing client script and localStorage fallback', () => {
|
||||||
const ok = evaluatePageDataHtmlContent(SURVEY_HTML, { relativePath: 'public/diet-survey.html' });
|
const ok = evaluatePageDataHtmlContent(SURVEY_HTML, { relativePath: 'public/diet-survey.html' });
|
||||||
assert.deepEqual(ok.issues, []);
|
assert.deepEqual(ok.issues, []);
|
||||||
@@ -44,6 +48,30 @@ test('evaluatePageDataHtmlContent flags missing client script and localStorage f
|
|||||||
assert.ok(bad.issues.includes('forbidden_local_storage'));
|
assert.ok(bad.issues.includes('forbidden_local_storage'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('evaluatePageDataHtmlContent flags forbidden /api/page-data passthrough', () => {
|
||||||
|
const bad = evaluatePageDataHtmlContent(LEGACY_API_HTML, { relativePath: 'public/daily-todo.html' });
|
||||||
|
assert.ok(bad.issues.includes('forbidden_legacy_page_data_api'));
|
||||||
|
assert.ok(bad.issues.includes('missing_page_data_client_script'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finish guard blocks delivery when html uses legacy page-data API', () => {
|
||||||
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-legacy-'));
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(publishDir, 'public', 'daily-todo.html'), LEGACY_API_HTML, 'utf8');
|
||||||
|
const evaluation = evaluatePageDataFinishGuard({
|
||||||
|
publishDir,
|
||||||
|
agentText: '帮我设计一个调查问卷,要加一个后台',
|
||||||
|
messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/daily-todo.html' } } } }] }],
|
||||||
|
});
|
||||||
|
assert.equal(evaluation.pageDataIntent, true);
|
||||||
|
assert.ok(evaluation.htmlIssues.some((item) => item.issue === 'forbidden_legacy_page_data_api'));
|
||||||
|
assert.equal(evaluation.needsRepair, true);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('inferPageDataBindAccessMode chooses password for admin html', () => {
|
test('inferPageDataBindAccessMode chooses password for admin html', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
inferPageDataBindAccessMode('public/diet-survey-admin.html', SURVEY_HTML),
|
inferPageDataBindAccessMode('public/diet-survey-admin.html', SURVEY_HTML),
|
||||||
@@ -191,6 +219,28 @@ test('evaluatePageDataFinishGuard flags html when dataset is not registered', ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('collectPageDataDeliveryArtifacts uses Portal base when H5_PUBLIC_BASE_URL points at Vite', () => {
|
||||||
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-portal-url-'));
|
||||||
|
const previousBase = process.env.H5_PUBLIC_BASE_URL;
|
||||||
|
const previousDelivery = process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL;
|
||||||
|
delete process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL;
|
||||||
|
process.env.H5_PUBLIC_BASE_URL = 'http://127.0.0.1:5173';
|
||||||
|
process.env.H5_PORT = '8081';
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(publishDir, 'public', 'form.html'), SURVEY_HTML, 'utf8');
|
||||||
|
const artifacts = collectPageDataDeliveryArtifacts(publishDir);
|
||||||
|
assert.equal(artifacts.length, 1);
|
||||||
|
assert.match(artifacts[0].url, /^http:\/\/127\.0\.0\.1:8081\/MindSpace\//);
|
||||||
|
} finally {
|
||||||
|
if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
|
||||||
|
else process.env.H5_PUBLIC_BASE_URL = previousBase;
|
||||||
|
if (previousDelivery == null) delete process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL;
|
||||||
|
else process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL = previousDelivery;
|
||||||
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('rewritePageDataDeliveryLinks rewrites publication route urls to MindSpace workspace urls', () => {
|
test('rewritePageDataDeliveryLinks rewrites publication route urls to MindSpace workspace urls', () => {
|
||||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-urls-'));
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-urls-'));
|
||||||
const previousBase = process.env.H5_PUBLIC_BASE_URL;
|
const previousBase = process.env.H5_PUBLIC_BASE_URL;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import {
|
import {
|
||||||
detectPageDataDatasetUsageFromHtml,
|
detectPageDataDatasetUsageFromHtml,
|
||||||
|
htmlUsesForbiddenLegacyPageDataApi,
|
||||||
inferPageDataBindAccessMode,
|
inferPageDataBindAccessMode,
|
||||||
} from './page-data-html-detect.mjs';
|
} from './page-data-html-detect.mjs';
|
||||||
import { listPageAccessPolicies, readPageAccessPolicy } from './page-data-policy-store.mjs';
|
import { listPageAccessPolicies, readPageAccessPolicy } from './page-data-policy-store.mjs';
|
||||||
@@ -107,12 +108,23 @@ export async function assessPageDataHtmlBinding({
|
|||||||
findPageByRelativePath,
|
findPageByRelativePath,
|
||||||
}) {
|
}) {
|
||||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||||
|
const reasons = [];
|
||||||
|
|
||||||
|
if (htmlUsesForbiddenLegacyPageDataApi(html)) {
|
||||||
|
reasons.push('forbidden_legacy_page_data_api');
|
||||||
|
}
|
||||||
|
|
||||||
if (!usage.size) {
|
if (!usage.size) {
|
||||||
return { bound: true, reasons: [], pageId: null };
|
return {
|
||||||
|
bound: reasons.length === 0,
|
||||||
|
reasons: [...new Set(reasons)],
|
||||||
|
pageId: null,
|
||||||
|
policy: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = assessWorkspacePageDataReadiness({ publishDir, relativePath, html });
|
const workspace = assessWorkspacePageDataReadiness({ publishDir, relativePath, html });
|
||||||
const reasons = [...workspace.reasons];
|
reasons.push(...workspace.reasons);
|
||||||
|
|
||||||
let pageId = null;
|
let pageId = null;
|
||||||
if (typeof findPageByRelativePath === 'function') {
|
if (typeof findPageByRelativePath === 'function') {
|
||||||
@@ -147,6 +159,13 @@ export async function assessPageDataHtmlBinding({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pool && pageId) {
|
||||||
|
const publication = await queryOnlinePublication(pool, pageId);
|
||||||
|
const expectedAccessMode = inferPageDataBindAccessMode(relativePath, html);
|
||||||
|
if (publication && String(publication.access_mode ?? '').trim() !== expectedAccessMode) {
|
||||||
|
reasons.push('publication_access_mode_mismatch');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -219,12 +238,30 @@ export async function verifyPageDataDeliveryArtifacts({
|
|||||||
}) {
|
}) {
|
||||||
const failures = [];
|
const failures = [];
|
||||||
for (const artifact of artifacts) {
|
for (const artifact of artifacts) {
|
||||||
if (artifact.isAdmin) continue;
|
|
||||||
const relativePath = artifact.relativePath;
|
const relativePath = artifact.relativePath;
|
||||||
const html = await fs.readFile(artifact.localPath, 'utf8').catch(() => '');
|
const html = await fs.readFile(artifact.localPath, 'utf8').catch(() => '');
|
||||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||||
const insertDataset = [...usage.entries()].find(([, perms]) => perms.insert)?.[0] ?? null;
|
const insertDataset = [...usage.entries()].find(([, perms]) => perms.insert)?.[0] ?? null;
|
||||||
if (!insertDataset) continue;
|
|
||||||
|
const assessment = await assessPageDataHtmlBinding({
|
||||||
|
pool,
|
||||||
|
userId,
|
||||||
|
publishDir,
|
||||||
|
relativePath,
|
||||||
|
html,
|
||||||
|
findPageByRelativePath,
|
||||||
|
});
|
||||||
|
if (!assessment.bound) {
|
||||||
|
failures.push({
|
||||||
|
relativePath,
|
||||||
|
stage: 'binding',
|
||||||
|
reason: assessment.reasons.join(',') || 'not_bound',
|
||||||
|
bindingReasons: assessment.reasons,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artifact.isAdmin || !insertDataset) continue;
|
||||||
|
|
||||||
const injection = await fetchWorkspaceHtmlInjection({ url: artifact.url, fetchImpl });
|
const injection = await fetchWorkspaceHtmlInjection({ url: artifact.url, fetchImpl });
|
||||||
if (!injection.ok) {
|
if (!injection.ok) {
|
||||||
@@ -234,15 +271,7 @@ export async function verifyPageDataDeliveryArtifacts({
|
|||||||
|
|
||||||
const pageId = injection.pageId;
|
const pageId = injection.pageId;
|
||||||
const policy =
|
const policy =
|
||||||
readPageAccessPolicy(publishDir, pageId) ??
|
readPageAccessPolicy(publishDir, pageId) ?? assessment.policy;
|
||||||
(await assessPageDataHtmlBinding({
|
|
||||||
pool,
|
|
||||||
userId,
|
|
||||||
publishDir,
|
|
||||||
relativePath,
|
|
||||||
html,
|
|
||||||
findPageByRelativePath,
|
|
||||||
})).policy;
|
|
||||||
|
|
||||||
const smoke = await smokeTestPageDataInsert({
|
const smoke = await smokeTestPageDataInsert({
|
||||||
apiBase,
|
apiBase,
|
||||||
@@ -255,14 +284,6 @@ export async function verifyPageDataDeliveryArtifacts({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assessment = await assessPageDataHtmlBinding({
|
|
||||||
pool,
|
|
||||||
userId,
|
|
||||||
publishDir,
|
|
||||||
relativePath,
|
|
||||||
html,
|
|
||||||
findPageByRelativePath,
|
|
||||||
});
|
|
||||||
failures.push({
|
failures.push({
|
||||||
relativePath,
|
relativePath,
|
||||||
stage: 'insert_smoke',
|
stage: 'insert_smoke',
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const PAGE_DATA_CLIENT_SCRIPT_PATTERN = /\/assets\/page-data-client\.js/i;
|
const PAGE_DATA_CLIENT_SCRIPT_PATTERN = /\/assets\/page-data-client\.js/i;
|
||||||
|
const FORBIDDEN_LEGACY_PAGE_DATA_API_PATTERN = /\/api\/page-data\b/i;
|
||||||
|
|
||||||
|
export function htmlUsesForbiddenLegacyPageDataApi(html) {
|
||||||
|
return FORBIDDEN_LEGACY_PAGE_DATA_API_PATTERN.test(String(html ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
export function htmlUsesPageDataApi(html) {
|
export function htmlUsesPageDataApi(html) {
|
||||||
const content = String(html ?? '');
|
const content = String(html ?? '');
|
||||||
@@ -10,7 +15,8 @@ export function htmlUsesPageDataApi(html) {
|
|||||||
return (
|
return (
|
||||||
usage.size > 0 ||
|
usage.size > 0 ||
|
||||||
PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) ||
|
PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) ||
|
||||||
/\bMindSpacePageData\b/.test(content)
|
/\bMindSpacePageData\b/.test(content) ||
|
||||||
|
htmlUsesForbiddenLegacyPageDataApi(content)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ import {
|
|||||||
assertPolicyMatchesHtmlDatasets,
|
assertPolicyMatchesHtmlDatasets,
|
||||||
buildPageDataPolicyDatasetsFromRegistry,
|
buildPageDataPolicyDatasetsFromRegistry,
|
||||||
detectPageDataDatasetUsageFromHtml,
|
detectPageDataDatasetUsageFromHtml,
|
||||||
|
htmlUsesForbiddenLegacyPageDataApi,
|
||||||
|
htmlUsesPageDataApi,
|
||||||
} from './page-data-html-detect.mjs';
|
} from './page-data-html-detect.mjs';
|
||||||
|
|
||||||
|
test('htmlUsesForbiddenLegacyPageDataApi detects /api/page-data passthrough', () => {
|
||||||
|
const html = `fetch('/api/page-data/daily_todo', { method: 'POST' })`;
|
||||||
|
assert.equal(htmlUsesForbiddenLegacyPageDataApi(html), true);
|
||||||
|
assert.equal(htmlUsesPageDataApi(html), true);
|
||||||
|
assert.equal(detectPageDataDatasetUsageFromHtml(html).size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('detectPageDataDatasetUsageFromHtml finds insert and read datasets', () => {
|
test('detectPageDataDatasetUsageFromHtml finds insert and read datasets', () => {
|
||||||
const html = `
|
const html = `
|
||||||
await c.insertRow('tkmind_exp_survey', { satisfaction: '满意' });
|
await c.insertRow('tkmind_exp_survey', { satisfaction: '满意' });
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { normalizePageAccessPolicy } from './page-access-policy.mjs';
|
|||||||
import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs';
|
import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs';
|
||||||
import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs';
|
import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs';
|
||||||
import { createMindSpacePublicUrl } from './mindspace-canonical-url.mjs';
|
import { createMindSpacePublicUrl } from './mindspace-canonical-url.mjs';
|
||||||
import { resolvePublicBaseUrl } from './user-publish.mjs';
|
import { resolvePageDataDeliveryBaseUrl } from './user-publish.mjs';
|
||||||
import { createUserDataSpaceService } from './user-data-space-service.mjs';
|
import { createUserDataSpaceService } from './user-data-space-service.mjs';
|
||||||
import {
|
import {
|
||||||
assertPolicyMatchesHtmlDatasets,
|
assertPolicyMatchesHtmlDatasets,
|
||||||
@@ -127,7 +127,7 @@ async function ensureWorkspaceHtmlPublished({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildWorkspacePublicUrl(userId, relativePath) {
|
function buildWorkspacePublicUrl(userId, relativePath) {
|
||||||
const publicBaseUrl = resolvePublicBaseUrl();
|
const publicBaseUrl = resolvePageDataDeliveryBaseUrl();
|
||||||
return createMindSpacePublicUrl({
|
return createMindSpacePublicUrl({
|
||||||
publicBaseUrl,
|
publicBaseUrl,
|
||||||
ownerKey: userId,
|
ownerKey: userId,
|
||||||
|
|||||||
@@ -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 交付链接必须用 Portal(8081),不要用 Vite UI(5173)。');
|
||||||
|
|
||||||
|
const passed = insert.status === 201 && admin.listStatus === 200;
|
||||||
|
console.log(`\n${passed ? '✔ 修复完成,链路可用' : '✘ 仍有问题,请检查上方输出'}`);
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
process.exit(passed ? 0 : 1);
|
||||||
@@ -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);
|
||||||
@@ -50,6 +50,28 @@ export function resolvePublicBaseUrl(env = process.env) {
|
|||||||
return (env.H5_PUBLIC_BASE_URL ?? 'https://m.tkmind.cn').replace(/\/$/, '');
|
return (env.H5_PUBLIC_BASE_URL ?? 'https://m.tkmind.cn').replace(/\/$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOCAL_VITE_PUBLIC_BASE_PATTERN = /^https?:\/\/(?:127\.0\.0\.1|localhost):5173$/i;
|
||||||
|
|
||||||
|
/** Agent 交付的 MindSpace 公开页 + Page Data API 应走 Portal,本地开发时勿用 Vite 5173。 */
|
||||||
|
export function resolvePageDataDeliveryBaseUrl(env = process.env) {
|
||||||
|
const explicit = String(
|
||||||
|
env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL ?? env.H5_PORTAL_BASE_URL ?? '',
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
if (explicit) return explicit;
|
||||||
|
|
||||||
|
const publicBase = String(env.H5_PUBLIC_BASE_URL ?? '').trim().replace(/\/$/, '');
|
||||||
|
const portalPort = Number(env.H5_PORT ?? 8081);
|
||||||
|
const portalBase = `http://127.0.0.1:${portalPort}`;
|
||||||
|
|
||||||
|
if (publicBase && LOCAL_VITE_PUBLIC_BASE_PATTERN.test(publicBase)) {
|
||||||
|
return portalBase;
|
||||||
|
}
|
||||||
|
if (publicBase) return publicBase;
|
||||||
|
return portalBase;
|
||||||
|
}
|
||||||
|
|
||||||
export const PUBLISH_KEY_UUID =
|
export const PUBLISH_KEY_UUID =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
PUBLIC_ZONE_DIR,
|
PUBLIC_ZONE_DIR,
|
||||||
resolveLegacyPublishDir,
|
resolveLegacyPublishDir,
|
||||||
resolveGoosedSandboxRoot,
|
resolveGoosedSandboxRoot,
|
||||||
|
resolvePageDataDeliveryBaseUrl,
|
||||||
} from './user-publish.mjs';
|
} from './user-publish.mjs';
|
||||||
import { syncSkillsToWorkspace } from './skills-registry.mjs';
|
import { syncSkillsToWorkspace } from './skills-registry.mjs';
|
||||||
import { listPlatformSkillCatalog } from './skills-registry.mjs';
|
import { listPlatformSkillCatalog } from './skills-registry.mjs';
|
||||||
@@ -157,3 +158,21 @@ test('isPathInsidePublishDir blocks escape', () => {
|
|||||||
assert.equal(isPathInsidePublishDir(publishDir, `/var/h5/${PUBLISH_ROOT_DIR}/${USER_ID}/report.html`), true);
|
assert.equal(isPathInsidePublishDir(publishDir, `/var/h5/${PUBLISH_ROOT_DIR}/${USER_ID}/report.html`), true);
|
||||||
assert.equal(isPathInsidePublishDir(publishDir, `/var/h5/${PUBLISH_ROOT_DIR}/other-id/x.html`), false);
|
assert.equal(isPathInsidePublishDir(publishDir, `/var/h5/${PUBLISH_ROOT_DIR}/other-id/x.html`), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolvePageDataDeliveryBaseUrl prefers Portal when public base points at local Vite', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolvePageDataDeliveryBaseUrl({ H5_PUBLIC_BASE_URL: 'http://127.0.0.1:5173', H5_PORT: '8081' }),
|
||||||
|
'http://127.0.0.1:8081',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolvePageDataDeliveryBaseUrl({
|
||||||
|
MEMIND_PAGE_DATA_DELIVERY_BASE_URL: 'http://127.0.0.1:8081',
|
||||||
|
H5_PUBLIC_BASE_URL: 'http://127.0.0.1:5173',
|
||||||
|
}),
|
||||||
|
'http://127.0.0.1:8081',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolvePageDataDeliveryBaseUrl({ H5_PUBLIC_BASE_URL: 'https://m.tkmind.cn' }),
|
||||||
|
'https://m.tkmind.cn',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user