From cef3750ac69034ff36d3511ad7b6244a5905a67a Mon Sep 17 00:00:00 2001 From: john Date: Sun, 12 Jul 2026 18:39:13 +0800 Subject: [PATCH] fix(page-data): require registered datasets before binding --- AGENTS.md | 1 + docs/regression-guards/README.md | 1 + .../page-data-delivery-contract.md | 28 +++++ page-data-workspace-bind.mjs | 107 ++++++++++++++---- page-data-workspace-bind.test.mjs | 89 +++++++++++++++ 5 files changed, 206 insertions(+), 20 deletions(-) create mode 100644 docs/regression-guards/page-data-delivery-contract.md diff --git a/AGENTS.md b/AGENTS.md index 158e52c..765502f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ bash scripts/check-release-ready.sh |------|------| | MindSpace 公开页 `edit_file` 落盘 + 聊天 Finish 不丢消息 | [docs/regression-guards/mindspace-publish-and-chat-finish.md](docs/regression-guards/mindspace-publish-and-chat-finish.md) | | MindSpace remote 页面 sync + storage 缺失缩略图 fallback | [docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md](docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md) | +| Page Data 数据集注册、绑定与交付验收 | [docs/regression-guards/page-data-delivery-contract.md](docs/regression-guards/page-data-delivery-contract.md) | 索引:[docs/regression-guards/README.md](docs/regression-guards/README.md) diff --git a/docs/regression-guards/README.md b/docs/regression-guards/README.md index 5f156eb..480ec51 100644 --- a/docs/regression-guards/README.md +++ b/docs/regression-guards/README.md @@ -8,6 +8,7 @@ |------|----------| | [mindspace-publish-and-chat-finish.md](./mindspace-publish-and-chat-finish.md) | ① `edit_file` 覆盖 `public/*.html` ② Finish 后聊天不清空、不暴露 agent 内部前缀 | | [mindspace-remote-page-sync-and-thumbnail.md](./mindspace-remote-page-sync-and-thumbnail.md) | ① remote 模式 public HTML 入库 sync ② storage 缺失时缩略图/读页回退 workspace HTML | +| [page-data-delivery-contract.md](./page-data-delivery-contract.md) | 数据集注册、绑定、真实 page UUID 与交付验收 | ## 自动化 diff --git a/docs/regression-guards/page-data-delivery-contract.md b/docs/regression-guards/page-data-delivery-contract.md new file mode 100644 index 0000000..e8c2b8a --- /dev/null +++ b/docs/regression-guards/page-data-delivery-contract.md @@ -0,0 +1,28 @@ +# Page Data 交付契约 + +Page Data 页面只有在下面所有条件满足后才可向用户交付链接: + +1. HTML 实际调用的每个 dataset 已在工作区 `.mindspace/private-data.sqlite` 注册。 +2. 每个注册 dataset 的真实 SQLite 表、声明字段和读写 action 都存在。 +3. page record、online publication 与 policy 使用同一个真实 page UUID。 +4. 公开页完成一次受权限约束的 insert smoke;后台页完成 password auth 后的 read smoke。 + +`private_data_bind_workspace_page` 是硬门:它在创建 page / publication / policy 前,必须从 SQLite registry 派生权限。Agent 传入的策略不能凭空创建 dataset 或字段权限。 + +运行时路径必须按语义区分: + +- `workspaceRoot`:`MindSpace/`,保存 HTML、policy 和 private-data.sqlite。 +- `storageRoot`:MindSpace service 的持久页面/资产存储。 +- `usersRoot`:登录用户目录。 + +不要通过 `MINDSPACE_STORAGE_ROOT` 推断 Page Data 的 workspaceRoot。Portal、MindSpace service 与 sandbox MCP 必须显式使用同一 workspace contract。 + +回归命令: + +```bash +npm run verify:page-data +npm run verify:mindspace-publish-guards:full +npm run verify:mindspace-page-sync-guards +``` + +涉及 H5 交付时,还必须验证:未注册 dataset 时不产生可用 Page Data policy,且最终链接交付被拒绝或进入明确 repair 状态。 diff --git a/page-data-workspace-bind.mjs b/page-data-workspace-bind.mjs index 6d675ff..c9f24b6 100644 --- a/page-data-workspace-bind.mjs +++ b/page-data-workspace-bind.mjs @@ -135,6 +135,84 @@ function buildWorkspacePublicUrl(userId, relativePath) { }); } +function assertRegisteredDatasetTables(userDataSpace, registryDatasets, usage) { + for (const dataset of registryDatasets) { + const requiredUsage = usage.get(dataset.name) ?? {}; + if (requiredUsage.insert && !dataset.actions.includes('insert')) { + throw Object.assign(new Error(`dataset「${dataset.name}」未开放写入`), { + code: 'dataset_action_not_registered', + datasetName: dataset.name, + action: 'insert', + }); + } + if (requiredUsage.read && !dataset.actions.includes('read')) { + throw Object.assign(new Error(`dataset「${dataset.name}」未开放读取`), { + code: 'dataset_action_not_registered', + datasetName: dataset.name, + action: 'read', + }); + } + const actualColumns = userDataSpace.listTableColumns(dataset.table); + if (!actualColumns.length) { + throw Object.assign(new Error(`dataset 对应表不存在:${dataset.table}`), { + code: 'table_not_found', + datasetName: dataset.name, + }); + } + const actualNames = new Set(actualColumns.map((column) => column.name)); + const configuredColumns = Object.values(dataset.columns ?? {}).flat(); + const missingColumns = [...new Set(configuredColumns)].filter((column) => !actualNames.has(column)); + if (missingColumns.length) { + throw Object.assign( + new Error(`dataset「${dataset.name}」注册字段不存在:${missingColumns.join(', ')}`), + { code: 'dataset_schema_mismatch', datasetName: dataset.name, missingColumns }, + ); + } + } +} + +/** + * Page Data policy must be derived from a registered SQLite dataset, never + * accepted solely from an Agent-supplied policy. This runs before page or + * publication creation so an incomplete data setup cannot leave a live page. + */ +export function resolveRegisteredPageDataPolicy({ + html, + userId, + accessMode, + pageDataPolicy = null, + userDataSpace, +} = {}) { + const usage = detectPageDataDatasetUsageFromHtml(html); + if (!usage.size) return null; + if (!userDataSpace) throw new Error('缺少 Page Data 数据空间'); + + if (pageDataPolicy?.datasets) { + assertPolicyMatchesHtmlDatasets(html, pageDataPolicy.datasets); + } + + const registryDatasets = userDataSpace.listDatasets(); + const datasets = buildPageDataPolicyDatasetsFromRegistry({ + html, + registryDatasets, + usage, + }); + assertRegisteredDatasetTables( + userDataSpace, + registryDatasets.filter((dataset) => datasets[dataset.name]), + usage, + ); + + return { + ...pageDataPolicy, + ownerUserId: String(pageDataPolicy?.ownerUserId ?? userId).trim(), + accessMode: pageDataPolicy?.accessMode ?? accessMode, + // The registry is authoritative for actions and allowed columns. Agent + // input can describe the page, but cannot manufacture a dataset policy. + datasets, + }; +} + export async function bindWorkspaceHtmlForPageData({ pool, h5Root, @@ -158,6 +236,15 @@ export async function bindWorkspaceHtmlForPageData({ const normalizedAccessMode = resolvePageDataBindAccess(accessMode); const resolvedPassword = resolvePageDataBindPassword(normalizedAccessMode, password); + const userDataSpace = createUserDataSpaceService({ workspaceRoot }); + const resolvedPageDataPolicy = resolveRegisteredPageDataPolicy({ + html: content, + userId, + accessMode: normalizedAccessMode, + pageDataPolicy, + userDataSpace, + }); + const mindSpacePages = createPageService(pool, { h5Root, storageRoot }); const mindSpacePublications = createPublicationService(pool, { h5Root, @@ -183,26 +270,6 @@ export async function bindWorkspaceHtmlForPageData({ }); let policy = null; - const htmlDatasetUsage = detectPageDataDatasetUsageFromHtml(content); - let resolvedPageDataPolicy = pageDataPolicy; - - if (htmlDatasetUsage.size) { - if (pageDataPolicy?.datasets) { - assertPolicyMatchesHtmlDatasets(content, pageDataPolicy.datasets); - } else { - const userDataSpace = createUserDataSpaceService({ workspaceRoot }); - const autoDatasets = buildPageDataPolicyDatasetsFromRegistry({ - html: content, - registryDatasets: userDataSpace.listDatasets(), - usage: htmlDatasetUsage, - }); - resolvedPageDataPolicy = { - ownerUserId: userId, - accessMode: normalizedAccessMode, - datasets: autoDatasets, - }; - } - } if (resolvedPageDataPolicy) { const ownerUserId = String(resolvedPageDataPolicy.ownerUserId ?? userId).trim(); diff --git a/page-data-workspace-bind.test.mjs b/page-data-workspace-bind.test.mjs index 3b0c239..cde72d8 100644 --- a/page-data-workspace-bind.test.mjs +++ b/page-data-workspace-bind.test.mjs @@ -4,8 +4,18 @@ import { DEFAULT_PAGE_DATA_ADMIN_PASSWORD, resolvePageDataBindAccess, resolvePageDataBindPassword, + resolveRegisteredPageDataPolicy, } from './page-data-workspace-bind.mjs'; +const HTML = ''; + +function createDataSpace({ datasets = [], tables = {} } = {}) { + return { + listDatasets: () => datasets, + listTableColumns: (table) => tables[table] ?? [], + }; +} + test('resolvePageDataBindPassword defaults for password mode', () => { assert.equal(resolvePageDataBindPassword('password', null), DEFAULT_PAGE_DATA_ADMIN_PASSWORD); assert.equal(resolvePageDataBindPassword('password', ''), DEFAULT_PAGE_DATA_ADMIN_PASSWORD); @@ -32,3 +42,82 @@ test('resolvePageDataBindAccess normalizes access mode', () => { assert.equal(resolvePageDataBindAccess('public'), 'public'); assert.equal(resolvePageDataBindAccess('password'), 'password'); }); + +test('binding rejects an Agent policy when its HTML dataset is not registered', () => { + assert.throws( + () => + resolveRegisteredPageDataPolicy({ + html: HTML, + userId: 'user-1', + accessMode: 'public', + pageDataPolicy: { datasets: { activity_signups: { insert: true } } }, + userDataSpace: createDataSpace(), + }), + (error) => error.code === 'dataset_not_registered', + ); +}); + +test('binding derives policy from the registered dataset and rejects a missing table', () => { + const dataset = { + name: 'activity_signups', + table: 'activity_signups', + actions: ['insert'], + columns: { insert: ['name'] }, + limits: {}, + }; + assert.throws( + () => + resolveRegisteredPageDataPolicy({ + html: HTML, + userId: 'user-1', + accessMode: 'public', + pageDataPolicy: { datasets: { activity_signups: { insert: true, columns: { insert: ['forged'] } } } }, + userDataSpace: createDataSpace({ datasets: [dataset] }), + }), + (error) => error.code === 'table_not_found', + ); +}); + +test('binding uses registered columns instead of Agent-supplied policy columns', () => { + const dataset = { + name: 'activity_signups', + table: 'activity_signups', + actions: ['insert'], + columns: { insert: ['name'] }, + limits: {}, + }; + const policy = resolveRegisteredPageDataPolicy({ + html: HTML, + userId: 'user-1', + accessMode: 'public', + pageDataPolicy: { datasets: { activity_signups: { insert: true, columns: { insert: ['forged'] } } } }, + userDataSpace: createDataSpace({ + datasets: [dataset], + tables: { activity_signups: [{ name: 'id' }, { name: 'name' }] }, + }), + }); + assert.deepEqual(policy.datasets.activity_signups.columns.insert, ['name']); +}); + +test('binding rejects a registered dataset that does not allow the HTML action', () => { + const dataset = { + name: 'activity_signups', + table: 'activity_signups', + actions: ['read'], + columns: { insert: ['name'], read: ['id', 'name'] }, + limits: {}, + }; + assert.throws( + () => + resolveRegisteredPageDataPolicy({ + html: HTML, + userId: 'user-1', + accessMode: 'public', + userDataSpace: createDataSpace({ + datasets: [dataset], + tables: { activity_signups: [{ name: 'id' }, { name: 'name' }] }, + }), + }), + (error) => error.code === 'dataset_action_not_registered' && error.action === 'insert', + ); +});