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 公开页 `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) |
|
| 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)
|
索引:[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-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 |
|
| [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({
|
export async function bindWorkspaceHtmlForPageData({
|
||||||
pool,
|
pool,
|
||||||
h5Root,
|
h5Root,
|
||||||
@@ -158,6 +236,15 @@ export async function bindWorkspaceHtmlForPageData({
|
|||||||
const normalizedAccessMode = resolvePageDataBindAccess(accessMode);
|
const normalizedAccessMode = resolvePageDataBindAccess(accessMode);
|
||||||
const resolvedPassword = resolvePageDataBindPassword(normalizedAccessMode, password);
|
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 mindSpacePages = createPageService(pool, { h5Root, storageRoot });
|
||||||
const mindSpacePublications = createPublicationService(pool, {
|
const mindSpacePublications = createPublicationService(pool, {
|
||||||
h5Root,
|
h5Root,
|
||||||
@@ -183,26 +270,6 @@ export async function bindWorkspaceHtmlForPageData({
|
|||||||
});
|
});
|
||||||
|
|
||||||
let policy = null;
|
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) {
|
if (resolvedPageDataPolicy) {
|
||||||
const ownerUserId = String(resolvedPageDataPolicy.ownerUserId ?? userId).trim();
|
const ownerUserId = String(resolvedPageDataPolicy.ownerUserId ?? userId).trim();
|
||||||
|
|||||||
@@ -4,8 +4,18 @@ import {
|
|||||||
DEFAULT_PAGE_DATA_ADMIN_PASSWORD,
|
DEFAULT_PAGE_DATA_ADMIN_PASSWORD,
|
||||||
resolvePageDataBindAccess,
|
resolvePageDataBindAccess,
|
||||||
resolvePageDataBindPassword,
|
resolvePageDataBindPassword,
|
||||||
|
resolveRegisteredPageDataPolicy,
|
||||||
} from './page-data-workspace-bind.mjs';
|
} 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', () => {
|
test('resolvePageDataBindPassword defaults for password mode', () => {
|
||||||
assert.equal(resolvePageDataBindPassword('password', null), DEFAULT_PAGE_DATA_ADMIN_PASSWORD);
|
assert.equal(resolvePageDataBindPassword('password', null), DEFAULT_PAGE_DATA_ADMIN_PASSWORD);
|
||||||
assert.equal(resolvePageDataBindPassword('password', ''), 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('public'), 'public');
|
||||||
assert.equal(resolvePageDataBindAccess('password'), 'password');
|
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