Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cef3750ac6 |
@@ -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)
|
||||
|
||||
|
||||
@@ -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 与交付验收 |
|
||||
|
||||
## 自动化
|
||||
|
||||
|
||||
@@ -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/<user>`,保存 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 状态。
|
||||
@@ -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();
|
||||
|
||||
@@ -4,8 +4,18 @@ import {
|
||||
DEFAULT_PAGE_DATA_ADMIN_PASSWORD,
|
||||
resolvePageDataBindAccess,
|
||||
resolvePageDataBindPassword,
|
||||
resolveRegisteredPageDataPolicy,
|
||||
} from './page-data-workspace-bind.mjs';
|
||||
|
||||
const HTML = '<script src="/assets/page-data-client.js"></script><script>MindSpacePageData.createClient().insertRow("activity_signups", { name: "test" })</script>';
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user