Compare commits

..

2 Commits

Author SHA1 Message Date
john cef3750ac6 fix(page-data): require registered datasets before binding 2026-07-12 18:39:13 +08:00
john 67d529c81e fix(mindspace): keep standalone service data paths under MindSpace root
Prevent Portal .env from overriding MindSpace storage/users roots, and
set launchd env plus runtime docs to use /Users/john/MindSpace paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:06:16 +08:00
8 changed files with 255 additions and 24 deletions
+1
View File
@@ -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)
+1
View File
@@ -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 状态。
@@ -4,6 +4,14 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MEMIND_ROOT="${MEMIND_SHARED_ROOT:-/Users/john/Project/Memind}"
# The standalone MindSpace service may read shared Memind secrets from the
# Portal env, but its own data roots must stay owned by the standalone service
# root. Preserve explicitly inherited standalone root overrides, then clear any
# values that may be sourced from the monolith env below.
EXPLICIT_MINDSPACE_SERVICE_H5_ROOT="${MINDSPACE_SERVICE_H5_ROOT-}"
EXPLICIT_MINDSPACE_STORAGE_ROOT="${MINDSPACE_STORAGE_ROOT-}"
EXPLICIT_H5_USERS_ROOT="${H5_USERS_ROOT-}"
if [[ -f "${MEMIND_ROOT}/.env" ]]; then
set -a
# shellcheck disable=SC1090
@@ -11,11 +19,38 @@ if [[ -f "${MEMIND_ROOT}/.env" ]]; then
set +a
fi
if [[ -z "${EXPLICIT_MINDSPACE_SERVICE_H5_ROOT}" ]]; then
unset MINDSPACE_SERVICE_H5_ROOT
fi
if [[ -z "${EXPLICIT_MINDSPACE_STORAGE_ROOT}" ]]; then
unset MINDSPACE_STORAGE_ROOT
fi
if [[ -z "${EXPLICIT_H5_USERS_ROOT}" ]]; then
unset H5_USERS_ROOT
fi
if [[ -f "${ROOT}/.env" ]]; then
set -a
# shellcheck disable=SC1090
source "${ROOT}/.env"
set +a
fi
if [[ -n "${EXPLICIT_MINDSPACE_SERVICE_H5_ROOT}" ]]; then
export MINDSPACE_SERVICE_H5_ROOT="${EXPLICIT_MINDSPACE_SERVICE_H5_ROOT}"
fi
if [[ -n "${EXPLICIT_MINDSPACE_STORAGE_ROOT}" ]]; then
export MINDSPACE_STORAGE_ROOT="${EXPLICIT_MINDSPACE_STORAGE_ROOT}"
fi
if [[ -n "${EXPLICIT_H5_USERS_ROOT}" ]]; then
export H5_USERS_ROOT="${EXPLICIT_H5_USERS_ROOT}"
fi
export NODE_ENV=production
export MINDSPACE_MEMIND_ROOT="${MINDSPACE_MEMIND_ROOT:-${ROOT}/memind-source}"
export MINDSPACE_SERVICE_H5_ROOT="${MINDSPACE_SERVICE_H5_ROOT:-${MEMIND_ROOT}}"
export MINDSPACE_STORAGE_ROOT="${MINDSPACE_STORAGE_ROOT:-${MEMIND_ROOT}/data/mindspace}"
export H5_USERS_ROOT="${H5_USERS_ROOT:-${MEMIND_ROOT}/users}"
export MINDSPACE_STORAGE_ROOT="${MINDSPACE_STORAGE_ROOT:-${ROOT}/data/mindspace}"
export H5_USERS_ROOT="${H5_USERS_ROOT:-${ROOT}/users}"
export MINDSPACE_SERVICE_PORT="${MINDSPACE_SERVICE_PORT:-8082}"
export MINDSPACE_SERVICE_HOST="${MINDSPACE_SERVICE_HOST:-127.0.0.1}"
export MINDSPACE_REMOTE_AUTH_TOKEN="${MINDSPACE_REMOTE_AUTH_TOKEN:-${TKMIND_SERVER__SECRET_KEY:-}}"
+87 -20
View File
@@ -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();
+89
View File
@@ -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',
);
});
+7 -2
View File
@@ -128,7 +128,11 @@ async function writeMetadata() {
'',
'Production runtime target:',
' /Users/john/MindSpace',
'Shared persisted data root:',
'Standalone persisted data root:',
' /Users/john/MindSpace/data/mindspace',
'Standalone users root:',
' /Users/john/MindSpace/users',
'Shared Portal/H5 root:',
' /Users/john/Project/Memind',
'',
'This artifact contains:',
@@ -145,7 +149,8 @@ async function writeMetadata() {
' users/ runtime data',
' .env / .env.local',
'',
'The production service keeps using shared persisted paths from /Users/john/Project/Memind.',
'The production service may read shared Portal secrets from /Users/john/Project/Memind/.env,',
'but standalone MindSpace data paths must stay under /Users/john/MindSpace.',
'',
`Git head ref: ${head.trim()}`,
].join('\n');
@@ -279,6 +279,10 @@ cat > "${SERVICE_PLIST}" <<EOF
<string>8082</string>
<key>MINDSPACE_SERVICE_HOST</key>
<string>127.0.0.1</string>
<key>MINDSPACE_STORAGE_ROOT</key>
<string>${APP_DIR}/data/mindspace</string>
<key>H5_USERS_ROOT</key>
<string>${APP_DIR}/users</string>
<key>MINDSPACE_SERVICE_AGENT_WORKER_ENABLED</key>
<string>true</string>
<key>MINDSPACE_SERVICE_WORKSPACE_MAINTENANCE</key>
@@ -306,6 +310,7 @@ mv "${NEW_DIR}" "${APP_DIR}"
APP_MOVED=1
say "启动 MindSpace service"
mkdir -p "${APP_DIR}/data/mindspace" "${APP_DIR}/users"
launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null 2>&1 || true
launchctl enable "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true
launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true