Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cef3750ac6 | |||
| 67d529c81e |
@@ -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 状态。
|
||||
@@ -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:-}}"
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user