Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cef3750ac6 | |||
| 67d529c81e | |||
| f9561cd91d | |||
| 73b308af0a | |||
| f437a37660 |
@@ -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 状态。
|
||||
@@ -235,7 +235,9 @@ export async function maybeRepairH5HtmlAfterFinish({
|
||||
repairAttemptsBySession.set(key, attempts + 1);
|
||||
|
||||
const prompt = buildH5HtmlRepairPrompt(evaluation);
|
||||
const requestId = `h5-html-repair-${crypto.randomUUID()}`;
|
||||
// goosed validates request_id as a UUID, so repair metadata must not be
|
||||
// encoded by prefixing the id. The message metadata below carries the type.
|
||||
const requestId = crypto.randomUUID();
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
|
||||
@@ -109,6 +109,7 @@ test('maybeRepairH5HtmlAfterFinish triggers proxy reply when enabled', async ()
|
||||
});
|
||||
assert.equal(result.repaired, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].requestId, /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
||||
assert.match(calls[0].userMessage.content[0].text, /icon\.svg/);
|
||||
} finally {
|
||||
if (previous == null) delete process.env.MEMIND_H5_HTML_FINISH_GUARD;
|
||||
|
||||
@@ -300,6 +300,7 @@ test('integration: H5 finish guard triggers repair prompt for invalid survey htm
|
||||
assert.equal(result.repaired, true);
|
||||
assert.equal(result.triggered, true);
|
||||
assert.equal(submitCalls.length, 1);
|
||||
assert.match(submitCalls[0].requestId, /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
||||
assert.match(submitCalls[0].userMessage.content[0].text, /localStorage/);
|
||||
assert.match(submitCalls[0].userMessage.content[0].text, /private_data_bind_workspace_page/);
|
||||
} finally {
|
||||
|
||||
@@ -2,11 +2,22 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isPageDataIntent } from './chat-skills.mjs';
|
||||
import { detectPageDataDatasetUsageFromHtml } from './page-data-html-detect.mjs';
|
||||
import {
|
||||
detectPageDataDatasetUsageFromHtml,
|
||||
htmlUsesPageDataApi,
|
||||
inferPageDataBindAccessMode,
|
||||
} from './page-data-html-detect.mjs';
|
||||
import { bindWorkspaceHtmlForPageData } from './page-data-workspace-bind.mjs';
|
||||
import { listPageAccessPolicies } from './page-data-policy-store.mjs';
|
||||
import { createPageService } from './mindspace-pages.mjs';
|
||||
import {
|
||||
assessPageDataHtmlBinding,
|
||||
assessWorkspacePageDataReadiness,
|
||||
verifyPageDataDeliveryArtifacts,
|
||||
} from './page-data-delivery-assess.mjs';
|
||||
import { buildPublicUrl, resolvePublicBaseUrl } from './user-publish.mjs';
|
||||
|
||||
export { inferPageDataBindAccessMode };
|
||||
|
||||
const PUBLICATION_ROUTE_LINK_PATTERN =
|
||||
/https?:\/\/[^\s<>"')\]]+\/u\/([0-9a-f-]{36}|[a-z0-9._-]+)\/pages\/([^\s<>"')\]]+)/gi;
|
||||
|
||||
@@ -143,26 +154,46 @@ export function collectPageDataPublicHtmlFiles(publishDir) {
|
||||
.filter((file) => file.evaluation.usesPageDataApi);
|
||||
}
|
||||
|
||||
export function inferPageDataBindAccessMode(relativePath, html) {
|
||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||
const hasRead = [...usage.values()].some((item) => item.read);
|
||||
const hasInsert = [...usage.values()].some((item) => item.insert);
|
||||
if (/-admin\.html$/i.test(String(relativePath ?? '')) || (hasRead && !hasInsert)) {
|
||||
return 'password';
|
||||
}
|
||||
return 'public';
|
||||
function isPageDataHtmlWorkspaceReady({ publishDir, relativePath, html }) {
|
||||
return assessWorkspacePageDataReadiness({ publishDir, relativePath, html }).ready;
|
||||
}
|
||||
|
||||
function pageHasBoundPolicy({ publishDir, relativePath, html }) {
|
||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||
if (!usage.size) return true;
|
||||
const policies = listPageAccessPolicies(publishDir);
|
||||
if (!policies.length) return false;
|
||||
const htmlDatasetNames = [...usage.keys()].sort().join(',');
|
||||
return policies.some((policy) => {
|
||||
const policyNames = Object.keys(policy?.datasets ?? {}).sort().join(',');
|
||||
return policyNames === htmlDatasetNames;
|
||||
});
|
||||
async function collectUnboundPageDataFiles({
|
||||
relevantFiles,
|
||||
publishDir,
|
||||
pool = null,
|
||||
userId = null,
|
||||
findPageByRelativePath = null,
|
||||
}) {
|
||||
const unboundFiles = [];
|
||||
for (const file of relevantFiles) {
|
||||
if (file.evaluation.usage?.size === 0) continue;
|
||||
if (pool && userId && typeof findPageByRelativePath === 'function') {
|
||||
const assessment = await assessPageDataHtmlBinding({
|
||||
pool,
|
||||
userId,
|
||||
publishDir,
|
||||
relativePath: file.relativePath,
|
||||
html: file.content,
|
||||
findPageByRelativePath,
|
||||
});
|
||||
if (!assessment.bound) {
|
||||
unboundFiles.push({
|
||||
...file,
|
||||
bindReasons: assessment.reasons,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isPageDataHtmlWorkspaceReady({
|
||||
publishDir,
|
||||
relativePath: file.relativePath,
|
||||
html: file.content,
|
||||
})) {
|
||||
unboundFiles.push(file);
|
||||
}
|
||||
}
|
||||
return unboundFiles;
|
||||
}
|
||||
|
||||
export function usedPageDataCollectSkill(messages = []) {
|
||||
@@ -231,7 +262,7 @@ export function evaluatePageDataFinishGuard({
|
||||
const unboundFiles = relevantFiles.filter(
|
||||
(file) =>
|
||||
file.evaluation.usage?.size > 0 &&
|
||||
!pageHasBoundPolicy({
|
||||
!isPageDataHtmlWorkspaceReady({
|
||||
publishDir,
|
||||
relativePath: file.relativePath,
|
||||
html: file.content,
|
||||
@@ -255,6 +286,43 @@ export function evaluatePageDataFinishGuard({
|
||||
};
|
||||
}
|
||||
|
||||
export async function evaluatePageDataFinishGuardAsync({
|
||||
publishDir,
|
||||
agentText = '',
|
||||
messages = [],
|
||||
requestStartedAt = 0,
|
||||
pool = null,
|
||||
userId = null,
|
||||
findPageByRelativePath = null,
|
||||
} = {}) {
|
||||
const base = evaluatePageDataFinishGuard({
|
||||
publishDir,
|
||||
agentText,
|
||||
messages,
|
||||
requestStartedAt,
|
||||
});
|
||||
const unboundFiles = await collectUnboundPageDataFiles({
|
||||
relevantFiles: base.relevantFiles,
|
||||
publishDir,
|
||||
pool,
|
||||
userId,
|
||||
findPageByRelativePath,
|
||||
});
|
||||
const needsRepair =
|
||||
base.pageDataIntent &&
|
||||
(base.htmlIssues.length > 0 ||
|
||||
unboundFiles.length > 0 ||
|
||||
(base.relevantFiles.length === 0 &&
|
||||
extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 &&
|
||||
usedPageDataCollectSkill(messages)));
|
||||
|
||||
return {
|
||||
...base,
|
||||
unboundFiles,
|
||||
needsRepair,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPageDataCollectFailureText() {
|
||||
return [
|
||||
'这次问卷/数据收集页面没有完成 Page Data API 绑定,所以我先不发链接。',
|
||||
@@ -284,7 +352,10 @@ export function buildPageDataCollectRepairPrompt({
|
||||
if (unboundFiles.length) {
|
||||
lines.push('', '尚未 bind 的 Page Data 页面:');
|
||||
for (const file of unboundFiles) {
|
||||
lines.push(`- ${file.relativePath}`);
|
||||
const reasons = Array.isArray(file.bindReasons) && file.bindReasons.length
|
||||
? ` (${file.bindReasons.join(', ')})`
|
||||
: '';
|
||||
lines.push(`- ${file.relativePath}${reasons}`);
|
||||
}
|
||||
}
|
||||
lines.push('', '修复完成前不要告诉用户“已发布/已可提交”。');
|
||||
@@ -354,6 +425,42 @@ export function resolvePageDataCollectOutcome({
|
||||
return { action: 'send', evaluation };
|
||||
}
|
||||
|
||||
export async function resolvePageDataCollectOutcomeAsync({
|
||||
reply,
|
||||
intent,
|
||||
publishDir,
|
||||
requestStartedAt = 0,
|
||||
pool = null,
|
||||
userId = null,
|
||||
findPageByRelativePath = null,
|
||||
} = {}) {
|
||||
const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim();
|
||||
if (!isPageDataIntent(agentText)) {
|
||||
return { action: 'skip' };
|
||||
}
|
||||
|
||||
const evaluation = await evaluatePageDataFinishGuardAsync({
|
||||
publishDir,
|
||||
agentText,
|
||||
messages: reply?.messages ?? [],
|
||||
requestStartedAt,
|
||||
pool,
|
||||
userId,
|
||||
findPageByRelativePath,
|
||||
});
|
||||
|
||||
if (evaluation.htmlIssues.length > 0) {
|
||||
return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation };
|
||||
}
|
||||
if (evaluation.unboundFiles.length > 0) {
|
||||
return { action: 'retry', reason: 'missing_bind', evaluation };
|
||||
}
|
||||
if (shouldRetryPageDataCollectReply({ reply, intent, publishDir, requestStartedAt })) {
|
||||
return { action: 'retry', reason: 'incomplete_delivery', evaluation };
|
||||
}
|
||||
return { action: 'send', evaluation };
|
||||
}
|
||||
|
||||
export function isPageDataFinishGuardEnabled(env = process.env) {
|
||||
return envFlag(env.MEMIND_PAGE_DATA_FINISH_GUARD, true);
|
||||
}
|
||||
@@ -370,11 +477,17 @@ export async function maybeAutoBindPageDataHtmlPages({
|
||||
h5Root,
|
||||
storageRoot,
|
||||
onlyRelativePaths = null,
|
||||
findPageByRelativePath = null,
|
||||
} = {}) {
|
||||
if (!pool) {
|
||||
return { bound: [], skipped: [], errors: [{ code: 'database_unconfigured' }] };
|
||||
}
|
||||
|
||||
const pageLookup =
|
||||
typeof findPageByRelativePath === 'function'
|
||||
? findPageByRelativePath
|
||||
: createPageService(pool, { h5Root, storageRoot }).findPageByRelativePath;
|
||||
|
||||
const bound = [];
|
||||
const skipped = [];
|
||||
const errors = [];
|
||||
@@ -390,11 +503,15 @@ export async function maybeAutoBindPageDataHtmlPages({
|
||||
skipped.push({ relativePath: file.relativePath, reason: 'no_dataset_usage' });
|
||||
continue;
|
||||
}
|
||||
if (pageHasBoundPolicy({
|
||||
const assessment = await assessPageDataHtmlBinding({
|
||||
pool,
|
||||
userId,
|
||||
publishDir,
|
||||
relativePath: file.relativePath,
|
||||
html: file.content,
|
||||
})) {
|
||||
findPageByRelativePath: pageLookup,
|
||||
});
|
||||
if (assessment.bound) {
|
||||
skipped.push({ relativePath: file.relativePath, reason: 'already_bound' });
|
||||
continue;
|
||||
}
|
||||
@@ -424,6 +541,32 @@ export async function maybeAutoBindPageDataHtmlPages({
|
||||
return { bound, skipped, errors };
|
||||
}
|
||||
|
||||
export async function ensurePageDataDeliveryReady({
|
||||
publishDir,
|
||||
userId,
|
||||
pool,
|
||||
h5Root,
|
||||
storageRoot,
|
||||
apiBase,
|
||||
artifacts = [],
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
if (!pool || !userId) {
|
||||
return { ok: false, failures: [{ stage: 'binding', reason: 'database_unconfigured' }] };
|
||||
}
|
||||
const pageService = createPageService(pool, { h5Root, storageRoot });
|
||||
const failures = await verifyPageDataDeliveryArtifacts({
|
||||
artifacts,
|
||||
publishDir,
|
||||
apiBase,
|
||||
pool,
|
||||
userId,
|
||||
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
|
||||
fetchImpl,
|
||||
});
|
||||
return { ok: failures.length === 0, failures };
|
||||
}
|
||||
|
||||
export async function maybeRepairPageDataAfterFinish({
|
||||
sessionId,
|
||||
userId,
|
||||
@@ -439,10 +582,14 @@ export async function maybeRepairPageDataAfterFinish({
|
||||
userText = '',
|
||||
} = {}) {
|
||||
const recentUserText = String(userText ?? '').trim();
|
||||
const evaluation = evaluatePageDataFinishGuard({
|
||||
const pageService = pool ? createPageService(pool, { h5Root, storageRoot }) : null;
|
||||
const evaluation = await evaluatePageDataFinishGuardAsync({
|
||||
publishDir,
|
||||
agentText: recentUserText,
|
||||
messages,
|
||||
pool,
|
||||
userId,
|
||||
findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null,
|
||||
});
|
||||
|
||||
if (!evaluation.pageDataIntent && evaluation.relevantFiles.length === 0) {
|
||||
@@ -457,12 +604,16 @@ export async function maybeRepairPageDataAfterFinish({
|
||||
h5Root,
|
||||
storageRoot,
|
||||
onlyRelativePaths: evaluation.relevantFiles.map((file) => file.relativePath),
|
||||
findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null,
|
||||
});
|
||||
|
||||
const afterBind = evaluatePageDataFinishGuard({
|
||||
const afterBind = await evaluatePageDataFinishGuardAsync({
|
||||
publishDir,
|
||||
agentText: recentUserText,
|
||||
messages,
|
||||
pool,
|
||||
userId,
|
||||
findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null,
|
||||
});
|
||||
|
||||
if (!afterBind.needsRepair) {
|
||||
@@ -495,7 +646,9 @@ export async function maybeRepairPageDataAfterFinish({
|
||||
repairAttemptsBySession.set(key, attempts + 1);
|
||||
|
||||
const prompt = buildPageDataCollectRepairPrompt(afterBind);
|
||||
const requestId = `page-data-repair-${crypto.randomUUID()}`;
|
||||
// goosed validates request_id as a UUID. Keep the repair classification in
|
||||
// message metadata rather than making the request id non-conformant.
|
||||
const requestId = crypto.randomUUID();
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
|
||||
@@ -114,7 +114,7 @@ test('maybeAutoBindPageDataHtmlPages skips invalid html', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('evaluatePageDataFinishGuard passes when policy already exists', () => {
|
||||
test('evaluatePageDataFinishGuard flags html when dataset is not registered', () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-bound-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||||
@@ -136,7 +136,7 @@ test('evaluatePageDataFinishGuard passes when policy already exists', () => {
|
||||
agentText: '调查问卷和后台',
|
||||
messages: [],
|
||||
});
|
||||
assert.equal(evaluation.unboundFiles.length, 0);
|
||||
assert.equal(evaluation.unboundFiles.length, 1);
|
||||
assert.equal(evaluation.htmlIssues.length, 0);
|
||||
} finally {
|
||||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { htmlUsesPageDataApi } from './page-data-html-detect.mjs';
|
||||
import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
|
||||
|
||||
const PUBLIC_HTML_SKIP_DIRS = new Set([
|
||||
@@ -235,6 +236,10 @@ export async function syncGeneratedPagesFromPublicAssets({
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (htmlUsesPageDataApi(content)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const result = await upsertWorkspaceHtmlPage({
|
||||
pool,
|
||||
pageService,
|
||||
|
||||
@@ -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:-}}"
|
||||
|
||||
+4
-1
@@ -64,7 +64,10 @@
|
||||
"verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs",
|
||||
"verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime",
|
||||
"verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs",
|
||||
"verify:page-data": "node --test page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs mindspace-page-data-finish-guard.test.mjs mindspace-page-data-finish-guard.integration.test.mjs",
|
||||
"verify:page-data": "node --test page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs page-data-delivery-assess.test.mjs mindspace-page-data-finish-guard.test.mjs mindspace-page-data-finish-guard.integration.test.mjs",
|
||||
"verify:page-data-delivery": "node scripts/repair-page-data-workspace-bindings.mjs --dry-run",
|
||||
"repair:page-data-bindings": "node scripts/repair-page-data-workspace-bindings.mjs",
|
||||
"repair:page-data:103": "node scripts/ensure-page-data-datasets.mjs && node scripts/repair-page-data-workspace-bindings.mjs",
|
||||
"verify:wechat-channel-isolation": "node scripts/check-wechat-channel-isolation.mjs",
|
||||
"verify:goosed-proxy-boundary": "node scripts/check-goosed-proxy-boundary.mjs",
|
||||
"verify:h5-session-patches": "node scripts/verify-h5-session-patches.mjs",
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
detectPageDataDatasetUsageFromHtml,
|
||||
inferPageDataBindAccessMode,
|
||||
} from './page-data-html-detect.mjs';
|
||||
import { listPageAccessPolicies, readPageAccessPolicy } from './page-data-policy-store.mjs';
|
||||
import { createUserDataSpaceService } from './user-data-space-service.mjs';
|
||||
|
||||
const INJECTION_PAGE_ID_PATTERN =
|
||||
/window\.__MINDSPACE_PAGE_DATA__\s*=\s*\{[^}]*"pageId"\s*:\s*"([^"]+)"/i;
|
||||
const INJECTION_META_PAGE_ID_PATTERN =
|
||||
/<meta[^>]+name=["']mindspace-page-data-page-id["'][^>]+content=["']([^"']+)["']/i;
|
||||
|
||||
export function extractInjectedPageIdFromHtml(html) {
|
||||
const content = String(html ?? '');
|
||||
const fromScript = content.match(INJECTION_PAGE_ID_PATTERN)?.[1]?.trim();
|
||||
if (fromScript) return fromScript;
|
||||
return content.match(INJECTION_META_PAGE_ID_PATTERN)?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
export function buildSmokeInsertRow(policy, datasetName) {
|
||||
const columns = policy?.datasets?.[datasetName]?.columns?.insert ?? [];
|
||||
const row = {};
|
||||
for (const column of columns) {
|
||||
if (/rating|score|星级|评分/i.test(column)) {
|
||||
row[column] = 3;
|
||||
} else if (/phone|手机|电话/i.test(column)) {
|
||||
row[column] = '13800000000';
|
||||
} else {
|
||||
row[column] = 'smoke-test';
|
||||
}
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function queryOnlinePublication(pool, pageId) {
|
||||
if (!pool || !pageId) return null;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, page_id, status, access_mode
|
||||
FROM h5_publish_records
|
||||
WHERE page_id = ? AND status = 'online'
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 1`,
|
||||
[pageId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
function findWorkspacePolicyForHtml({ publishDir, relativePath, html }) {
|
||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||
const htmlDatasetNames = [...usage.keys()].sort().join(',');
|
||||
const expectedAccessMode = inferPageDataBindAccessMode(relativePath, html);
|
||||
for (const policy of listPageAccessPolicies(publishDir)) {
|
||||
const policyNames = Object.keys(policy?.datasets ?? {}).sort().join(',');
|
||||
if (policyNames !== htmlDatasetNames) continue;
|
||||
if (String(policy.accessMode ?? '').trim() !== expectedAccessMode) continue;
|
||||
let matches = true;
|
||||
for (const [datasetName, perms] of usage) {
|
||||
const policyDataset = policy.datasets?.[datasetName];
|
||||
if (!policyDataset) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
if (perms.insert && !policyDataset.insert) matches = false;
|
||||
if (perms.read && !policyDataset.read) matches = false;
|
||||
}
|
||||
if (matches) return policy;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function assessWorkspacePageDataReadiness({ publishDir, relativePath, html }) {
|
||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||
if (!usage.size) {
|
||||
return { ready: true, reasons: [] };
|
||||
}
|
||||
|
||||
const reasons = [];
|
||||
const dataSpace = createUserDataSpaceService({ workspaceRoot: publishDir });
|
||||
for (const datasetName of usage.keys()) {
|
||||
if (!dataSpace.getDataset(datasetName)) {
|
||||
reasons.push(`dataset_not_registered:${datasetName}`);
|
||||
}
|
||||
}
|
||||
|
||||
const policy = findWorkspacePolicyForHtml({ publishDir, relativePath, html });
|
||||
if (!policy) {
|
||||
reasons.push('missing_workspace_policy');
|
||||
}
|
||||
|
||||
return { ready: reasons.length === 0, reasons, policy };
|
||||
}
|
||||
|
||||
export async function assessPageDataHtmlBinding({
|
||||
pool,
|
||||
userId,
|
||||
publishDir,
|
||||
relativePath,
|
||||
html,
|
||||
findPageByRelativePath,
|
||||
}) {
|
||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||
if (!usage.size) {
|
||||
return { bound: true, reasons: [], pageId: null };
|
||||
}
|
||||
|
||||
const workspace = assessWorkspacePageDataReadiness({ publishDir, relativePath, html });
|
||||
const reasons = [...workspace.reasons];
|
||||
|
||||
let pageId = null;
|
||||
if (typeof findPageByRelativePath === 'function') {
|
||||
const page = await findPageByRelativePath(userId, relativePath).catch(() => null);
|
||||
pageId = page?.id ? String(page.id).trim() : null;
|
||||
if (!pageId) {
|
||||
reasons.push('missing_page_record');
|
||||
}
|
||||
} else {
|
||||
reasons.push('missing_page_lookup');
|
||||
}
|
||||
|
||||
if (pageId) {
|
||||
const policy = readPageAccessPolicy(publishDir, pageId);
|
||||
if (!policy) {
|
||||
reasons.push('missing_policy_for_page');
|
||||
} else {
|
||||
const htmlDatasetNames = [...usage.keys()].sort().join(',');
|
||||
const policyNames = Object.keys(policy.datasets ?? {}).sort().join(',');
|
||||
if (htmlDatasetNames !== policyNames) {
|
||||
reasons.push('policy_dataset_mismatch');
|
||||
}
|
||||
const expectedAccessMode = inferPageDataBindAccessMode(relativePath, html);
|
||||
if (String(policy.accessMode ?? '').trim() !== expectedAccessMode) {
|
||||
reasons.push('policy_access_mode_mismatch');
|
||||
}
|
||||
for (const [datasetName, perms] of usage) {
|
||||
const policyDataset = policy.datasets?.[datasetName];
|
||||
if (!policyDataset) continue;
|
||||
if (perms.insert && !policyDataset.insert) reasons.push('missing_insert_permission');
|
||||
if (perms.read && !policyDataset.read) reasons.push('missing_read_permission');
|
||||
}
|
||||
}
|
||||
|
||||
const publication = await queryOnlinePublication(pool, pageId);
|
||||
if (!publication) {
|
||||
reasons.push('missing_online_publication');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bound: reasons.length === 0,
|
||||
reasons: [...new Set(reasons)],
|
||||
pageId,
|
||||
policy: pageId ? readPageAccessPolicy(publishDir, pageId) : workspace.policy ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceHtmlInjection({ url, fetchImpl = fetch }) {
|
||||
const response = await fetchImpl(String(url));
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `http_${response.status}`,
|
||||
pageId: null,
|
||||
html: '',
|
||||
};
|
||||
}
|
||||
const html = await response.text();
|
||||
const pageId = extractInjectedPageIdFromHtml(html);
|
||||
if (!pageId) {
|
||||
return { ok: false, reason: 'missing_page_data_injection', pageId: null, html };
|
||||
}
|
||||
return { ok: true, reason: null, pageId, html };
|
||||
}
|
||||
|
||||
export async function smokeTestPageDataInsert({
|
||||
apiBase,
|
||||
pageId,
|
||||
dataset,
|
||||
policy,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const base = String(apiBase ?? '').replace(/\/$/, '') || '/api';
|
||||
const row = buildSmokeInsertRow(policy, dataset);
|
||||
if (!pageId || !dataset || !Object.keys(row).length) {
|
||||
return { ok: false, reason: 'smoke_payload_unavailable', status: 0, body: null };
|
||||
}
|
||||
|
||||
const response = await fetchImpl(
|
||||
`${base}/public/pages/${encodeURIComponent(pageId)}/data/${encodeURIComponent(dataset)}/rows`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify(row),
|
||||
},
|
||||
);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: body?.error?.code ?? 'insert_failed',
|
||||
status: response.status,
|
||||
body,
|
||||
};
|
||||
}
|
||||
return { ok: true, reason: null, status: response.status, body };
|
||||
}
|
||||
|
||||
export async function verifyPageDataDeliveryArtifacts({
|
||||
artifacts = [],
|
||||
publishDir,
|
||||
apiBase,
|
||||
pool,
|
||||
userId,
|
||||
findPageByRelativePath,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const failures = [];
|
||||
for (const artifact of artifacts) {
|
||||
if (artifact.isAdmin) continue;
|
||||
const relativePath = artifact.relativePath;
|
||||
const html = await fs.readFile(artifact.localPath, 'utf8').catch(() => '');
|
||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||
const insertDataset = [...usage.entries()].find(([, perms]) => perms.insert)?.[0] ?? null;
|
||||
if (!insertDataset) continue;
|
||||
|
||||
const injection = await fetchWorkspaceHtmlInjection({ url: artifact.url, fetchImpl });
|
||||
if (!injection.ok) {
|
||||
failures.push({ relativePath, stage: 'injection', reason: injection.reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
const assessment = await assessPageDataHtmlBinding({
|
||||
pool,
|
||||
userId,
|
||||
publishDir,
|
||||
relativePath,
|
||||
html,
|
||||
findPageByRelativePath,
|
||||
});
|
||||
if (!assessment.bound) {
|
||||
failures.push({
|
||||
relativePath,
|
||||
stage: 'binding',
|
||||
reason: assessment.reasons.join(','),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const smoke = await smokeTestPageDataInsert({
|
||||
apiBase,
|
||||
pageId: assessment.pageId,
|
||||
dataset: insertDataset,
|
||||
policy: assessment.policy,
|
||||
fetchImpl,
|
||||
});
|
||||
if (!smoke.ok) {
|
||||
failures.push({
|
||||
relativePath,
|
||||
stage: 'insert_smoke',
|
||||
reason: smoke.reason,
|
||||
status: smoke.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildSmokeInsertRow,
|
||||
extractInjectedPageIdFromHtml,
|
||||
} from './page-data-delivery-assess.mjs';
|
||||
|
||||
test('extractInjectedPageIdFromHtml reads injected pageId', () => {
|
||||
const html = `<html><head>
|
||||
<meta name="mindspace-page-data-page-id" content="page-abc">
|
||||
<script>window.__MINDSPACE_PAGE_DATA__={"pageId":"page-abc","accessMode":"public"};</script>
|
||||
</head></html>`;
|
||||
assert.equal(extractInjectedPageIdFromHtml(html), 'page-abc');
|
||||
});
|
||||
|
||||
test('buildSmokeInsertRow fills insert columns', () => {
|
||||
const row = buildSmokeInsertRow(
|
||||
{
|
||||
datasets: {
|
||||
dining_survey: {
|
||||
columns: { insert: ['q1_frequency', 'overall_rating', 'phone'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
'dining_survey',
|
||||
);
|
||||
assert.equal(row.q1_frequency, 'smoke-test');
|
||||
assert.equal(row.overall_rating, 3);
|
||||
assert.equal(row.phone, '13800000000');
|
||||
});
|
||||
@@ -2,6 +2,18 @@
|
||||
* 从 MindSpace 公开 HTML 中解析 Page Data API 引用的 dataset 与读写意图。
|
||||
*/
|
||||
|
||||
const PAGE_DATA_CLIENT_SCRIPT_PATTERN = /\/assets\/page-data-client\.js/i;
|
||||
|
||||
export function htmlUsesPageDataApi(html) {
|
||||
const content = String(html ?? '');
|
||||
const usage = detectPageDataDatasetUsageFromHtml(content);
|
||||
return (
|
||||
usage.size > 0 ||
|
||||
PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) ||
|
||||
/\bMindSpacePageData\b/.test(content)
|
||||
);
|
||||
}
|
||||
|
||||
export function detectPageDataDatasetUsageFromHtml(html) {
|
||||
const text = String(html ?? '');
|
||||
const datasets = new Map();
|
||||
@@ -20,6 +32,16 @@ export function detectPageDataDatasetUsageFromHtml(html) {
|
||||
return datasets;
|
||||
}
|
||||
|
||||
export function inferPageDataBindAccessMode(relativePath, html) {
|
||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||
const hasRead = [...usage.values()].some((item) => item.read);
|
||||
const hasInsert = [...usage.values()].some((item) => item.insert);
|
||||
if (/-admin\.html$/i.test(String(relativePath ?? '')) || (hasRead && !hasInsert)) {
|
||||
return 'password';
|
||||
}
|
||||
return 'public';
|
||||
}
|
||||
|
||||
export function assertPolicyMatchesHtmlDatasets(html, policyDatasets) {
|
||||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||||
const htmlNames = [...usage.keys()].sort();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 为已知破损问卷补注册 dataset(表已存在但 __page_data_datasets 未登记的场景)。
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createUserDataSpaceService } from '../user-data-space-service.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
const PRESETS = {
|
||||
'a70ff537-8908-486e-9b6c-042e07cc25db': [
|
||||
{
|
||||
name: 'company_suggestions',
|
||||
table: 'company_suggestions',
|
||||
description: '公司建议问卷调查',
|
||||
actions: ['read', 'insert'],
|
||||
columns: {
|
||||
read: [
|
||||
'id',
|
||||
'department',
|
||||
'q_work_env',
|
||||
'q_management',
|
||||
'q_teamwork',
|
||||
'q_salary_welfare',
|
||||
'q_dev_opportunity',
|
||||
'q_other_suggestion',
|
||||
'overall_rating',
|
||||
'created_at',
|
||||
],
|
||||
insert: [
|
||||
'department',
|
||||
'q_work_env',
|
||||
'q_management',
|
||||
'q_teamwork',
|
||||
'q_salary_welfare',
|
||||
'q_dev_opportunity',
|
||||
'q_other_suggestion',
|
||||
'overall_rating',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'diet_survey',
|
||||
table: 'diet_survey',
|
||||
description: '儿童饮食偏好调查',
|
||||
actions: ['read', 'insert'],
|
||||
columns: {
|
||||
read: ['id', 'child_age', 'veggie_habit', 'snack_type', 'created_at'],
|
||||
insert: ['child_age', 'veggie_habit', 'snack_type'],
|
||||
},
|
||||
},
|
||||
],
|
||||
'a6fb1e97-2b0f-447b-b138-4561d8e5c53e': [
|
||||
{
|
||||
name: 'dining_survey',
|
||||
table: 'dining_survey',
|
||||
description: '餐饮偏好调查问卷',
|
||||
sql: `CREATE TABLE IF NOT EXISTS dining_survey (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
q1_frequency TEXT NOT NULL DEFAULT '',
|
||||
q2_priority TEXT NOT NULL DEFAULT '',
|
||||
q3_cuisine TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
|
||||
);`,
|
||||
actions: ['read', 'insert'],
|
||||
columns: {
|
||||
read: ['id', 'q1_frequency', 'q2_priority', 'q3_cuisine', 'created_at'],
|
||||
insert: ['q1_frequency', 'q2_priority', 'q3_cuisine'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const userIds = process.argv.slice(2);
|
||||
const targets = userIds.length ? userIds : Object.keys(PRESETS);
|
||||
for (const userId of targets) {
|
||||
const presets = PRESETS[userId];
|
||||
if (!presets) {
|
||||
console.warn(`跳过 ${userId}:无 preset`);
|
||||
continue;
|
||||
}
|
||||
const workspaceRoot = path.join(repoRoot, 'MindSpace', userId);
|
||||
const dataSpace = createUserDataSpaceService({ workspaceRoot });
|
||||
for (const preset of presets) {
|
||||
if (preset.sql) {
|
||||
await dataSpace.executeSql(preset.sql);
|
||||
console.log(`[${userId}] 已确保表 ${preset.table}`);
|
||||
}
|
||||
if (!dataSpace.getDataset(preset.name)) {
|
||||
await dataSpace.upsertDataset({
|
||||
name: preset.name,
|
||||
table: preset.table,
|
||||
description: preset.description,
|
||||
actions: preset.actions,
|
||||
columns: preset.columns,
|
||||
});
|
||||
console.log(`[${userId}] 已注册 dataset ${preset.name}`);
|
||||
} else {
|
||||
console.log(`[${userId}] dataset ${preset.name} 已存在`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -133,6 +133,7 @@ if [[ "${SKIP_TESTS}" -ne 1 ]]; then
|
||||
cd "${ROOT}"
|
||||
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
|
||||
npm run verify:mindspace-publish-guards >/dev/null
|
||||
npm run verify:page-data >/dev/null
|
||||
)
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 扫描并补绑工作区中未完整交付的 Page Data 问卷页(publication + policy + dataset)。
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/repair-page-data-workspace-bindings.mjs
|
||||
* node scripts/repair-page-data-workspace-bindings.mjs --user-id <uuid>
|
||||
* node scripts/repair-page-data-workspace-bindings.mjs --dry-run
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { buildPublicUrl, PUBLISH_ROOT_DIR, resolvePublicBaseUrl } from '../user-publish.mjs';
|
||||
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
|
||||
import { createPageService } from '../mindspace-pages.mjs';
|
||||
import { assessPageDataHtmlBinding } from '../page-data-delivery-assess.mjs';
|
||||
import {
|
||||
collectPageDataPublicHtmlFiles,
|
||||
maybeAutoBindPageDataHtmlPages,
|
||||
} from '../mindspace-page-data-finish-guard.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { userId: null, dryRun: false };
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--dry-run') options.dryRun = true;
|
||||
else if (arg === '--user-id' && argv[i + 1]) options.userId = argv[++i];
|
||||
else if (arg === '-h' || arg === '--help') {
|
||||
console.log('Usage: node scripts/repair-page-data-workspace-bindings.mjs [--user-id <uuid>] [--dry-run]');
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function listWorkspaceUserIds(mindspaceRoot) {
|
||||
if (!fs.existsSync(mindspaceRoot)) return [];
|
||||
return fs
|
||||
.readdirSync(mindspaceRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && /^[0-9a-f-]{36}$/i.test(entry.name))
|
||||
.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
async function assessUserWorkspace({ userId, pool, h5Root, storageRoot, publicBaseUrl }) {
|
||||
const publishDir = path.join(h5Root, 'MindSpace', userId);
|
||||
if (!fs.existsSync(publishDir)) return [];
|
||||
const pageService = createPageService(pool, { h5Root, storageRoot });
|
||||
const broken = [];
|
||||
for (const file of collectPageDataPublicHtmlFiles(publishDir)) {
|
||||
if (file.evaluation.usage?.size === 0) continue;
|
||||
const assessment = await assessPageDataHtmlBinding({
|
||||
pool,
|
||||
userId,
|
||||
publishDir,
|
||||
relativePath: file.relativePath,
|
||||
html: file.content,
|
||||
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
|
||||
});
|
||||
if (!assessment.bound) {
|
||||
broken.push({
|
||||
userId,
|
||||
relativePath: file.relativePath,
|
||||
url: buildPublicUrl(publicBaseUrl, userId, file.relativePath),
|
||||
reasons: assessment.reasons,
|
||||
pageId: assessment.pageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return broken;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv);
|
||||
const pool = createDbPool();
|
||||
const h5Root = repoRoot;
|
||||
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
|
||||
const publicBaseUrl = resolvePublicBaseUrl();
|
||||
const userIds = options.userId ? [options.userId] : listWorkspaceUserIds(path.join(h5Root, 'MindSpace'));
|
||||
|
||||
const allBroken = [];
|
||||
for (const userId of userIds) {
|
||||
const broken = await assessUserWorkspace({
|
||||
userId,
|
||||
pool,
|
||||
h5Root,
|
||||
storageRoot,
|
||||
publicBaseUrl,
|
||||
});
|
||||
allBroken.push(...broken);
|
||||
}
|
||||
|
||||
if (!allBroken.length) {
|
||||
console.log('未发现需要补绑的 Page Data 页面。');
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`发现 ${allBroken.length} 个未完整绑定的 Page Data 页面:`);
|
||||
for (const item of allBroken) {
|
||||
console.log(`- [${item.userId}] ${item.relativePath}`);
|
||||
console.log(` url: ${item.url}`);
|
||||
console.log(` reasons: ${item.reasons.join(', ')}`);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('\n(dry-run) 未执行补绑。');
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const byUser = new Map();
|
||||
for (const item of allBroken) {
|
||||
const list = byUser.get(item.userId) ?? [];
|
||||
list.push(item.relativePath);
|
||||
byUser.set(item.userId, list);
|
||||
}
|
||||
|
||||
for (const [userId, relativePaths] of byUser.entries()) {
|
||||
const publishDir = path.join(h5Root, 'MindSpace', userId);
|
||||
const pageService = createPageService(pool, { h5Root, storageRoot });
|
||||
const result = await maybeAutoBindPageDataHtmlPages({
|
||||
pool,
|
||||
userId,
|
||||
publishDir,
|
||||
h5Root,
|
||||
storageRoot,
|
||||
onlyRelativePaths: relativePaths,
|
||||
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
|
||||
});
|
||||
console.log(
|
||||
`\n[user ${userId}] bound=${result.bound.length} skipped=${result.skipped.length} errors=${result.errors.length}`,
|
||||
);
|
||||
for (const item of result.bound) {
|
||||
console.log(` ✓ ${item.relativePath} -> ${item.pageId}`);
|
||||
}
|
||||
for (const item of result.errors) {
|
||||
console.log(` ✗ ${item.relativePath}: ${item.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -579,8 +579,14 @@ export async function verifyChildrenHobbyDietSurvey({
|
||||
if (response.status !== 200) {
|
||||
reporter.fail(label, `${response.status} ${url}`);
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
const html = await response.text();
|
||||
if (!html.includes('__MINDSPACE_PAGE_DATA__')) {
|
||||
reporter.fail(`${label} Page Data 注入`, '响应缺少 __MINDSPACE_PAGE_DATA__');
|
||||
ok = false;
|
||||
} else {
|
||||
reporter.pass(label, url);
|
||||
reporter.pass(`${label} Page Data 注入`, url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from './user-publish.mjs';
|
||||
import { buildCurrentTimeAgentPrefix, buildTaskRoutingAgentText } from './user-memory-profile.mjs';
|
||||
import { reconcileAgentSession } from './session-reconcile.mjs';
|
||||
import { consumeSessionEventsUntilFinish } from './session-reply-wait.mjs';
|
||||
import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs';
|
||||
import { createSessionBrokerMetrics, isSessionBrokerMetricsEnabled } from './session-broker-metrics.mjs';
|
||||
import { createImgproxySigner } from './imgproxy-signer.mjs';
|
||||
|
||||
+39
-3
@@ -32,8 +32,9 @@ import { resolveBillingTokenState } from './billing-token-state.mjs';
|
||||
import {
|
||||
buildPageDataCollectFailureText,
|
||||
buildPageDataDeliveryArtifactsFromBindResult,
|
||||
ensurePageDataDeliveryReady,
|
||||
maybeAutoBindPageDataHtmlPages,
|
||||
resolvePageDataCollectOutcome,
|
||||
resolvePageDataCollectOutcomeAsync,
|
||||
rewritePageDataDeliveryLinks,
|
||||
} from './mindspace-page-data-finish-guard.mjs';
|
||||
|
||||
@@ -1056,28 +1057,40 @@ async function enforcePageDataCollectDelivery({
|
||||
requestStartedAt = 0,
|
||||
notifyFailure,
|
||||
}) {
|
||||
let outcome = resolvePageDataCollectOutcome({
|
||||
let outcome = await resolvePageDataCollectOutcomeAsync({
|
||||
reply,
|
||||
intent,
|
||||
publishDir: workingDir,
|
||||
requestStartedAt,
|
||||
pool: pageDataFinishGuard?.pool ?? null,
|
||||
userId,
|
||||
findPageByRelativePath: null,
|
||||
});
|
||||
let autoBind = null;
|
||||
if (outcome.action === 'skip') return outcome;
|
||||
|
||||
if (pageDataFinishGuard?.pool) {
|
||||
const { createPageService } = await import('./mindspace-pages.mjs');
|
||||
const pageService = createPageService(pageDataFinishGuard.pool, {
|
||||
h5Root: pageDataFinishGuard.h5Root,
|
||||
storageRoot: pageDataFinishGuard.storageRoot,
|
||||
});
|
||||
autoBind = await maybeAutoBindPageDataHtmlPages({
|
||||
pool: pageDataFinishGuard.pool,
|
||||
userId,
|
||||
publishDir: workingDir,
|
||||
h5Root: pageDataFinishGuard.h5Root,
|
||||
storageRoot: pageDataFinishGuard.storageRoot,
|
||||
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
|
||||
});
|
||||
outcome = resolvePageDataCollectOutcome({
|
||||
outcome = await resolvePageDataCollectOutcomeAsync({
|
||||
reply,
|
||||
intent,
|
||||
publishDir: workingDir,
|
||||
requestStartedAt,
|
||||
pool: pageDataFinishGuard.pool,
|
||||
userId,
|
||||
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1095,6 +1108,29 @@ async function enforcePageDataCollectDelivery({
|
||||
const deliveryArtifacts = buildPageDataDeliveryArtifactsFromBindResult(autoBind, workingDir, {
|
||||
publicBaseUrl,
|
||||
});
|
||||
if (deliveryArtifacts.length > 0 && pageDataFinishGuard?.pool) {
|
||||
const { createPageService } = await import('./mindspace-pages.mjs');
|
||||
const pageService = createPageService(pageDataFinishGuard.pool, {
|
||||
h5Root: pageDataFinishGuard.h5Root,
|
||||
storageRoot: pageDataFinishGuard.storageRoot,
|
||||
});
|
||||
const deliveryCheck = await ensurePageDataDeliveryReady({
|
||||
publishDir: workingDir,
|
||||
userId,
|
||||
pool: pageDataFinishGuard.pool,
|
||||
h5Root: pageDataFinishGuard.h5Root,
|
||||
storageRoot: pageDataFinishGuard.storageRoot,
|
||||
apiBase: publicBaseUrl,
|
||||
artifacts: deliveryArtifacts,
|
||||
});
|
||||
if (!deliveryCheck.ok) {
|
||||
const text = buildPageDataCollectFailureText();
|
||||
if (typeof notifyFailure === 'function') {
|
||||
await notifyFailure(text);
|
||||
}
|
||||
throw markWechatUserNotified(new Error(text));
|
||||
}
|
||||
}
|
||||
if (deliveryArtifacts.length > 0 && reply && typeof reply.text === 'string') {
|
||||
reply.text = rewritePageDataDeliveryLinks(reply.text, deliveryArtifacts);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user