feat(page-data): complete Phase 4-5, ops UI, and publish integration

Add visitor roles, row-level scope, owner ops APIs, MySQL policy index,
Turnstile captcha, browser client SDK, publish-panel dataset binding,
acceptance tests, and usage documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-08 14:52:49 +08:00
parent 8d01553469
commit 6b0c633a75
40 changed files with 4056 additions and 60 deletions
+49 -3
View File
@@ -18,8 +18,9 @@ import { createScheduleService } from './schedule-service.mjs';
import { resolveScheduleTimestamp } from './schedule-time.mjs';
import { renderLongImage } from './mindspace-long-image.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
import { writePageAccessPolicy } from './page-data-policy-store.mjs';
import { normalizePageAccessPolicy } from './page-access-policy.mjs';
import { writePageAccessPolicy, readPageAccessPolicy } from './page-data-policy-store.mjs';
import { closePolicyDataset, normalizePageAccessPolicy } from './page-access-policy.mjs';
import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs';
const SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim();
if (!SANDBOX_ROOT) {
@@ -287,12 +288,38 @@ const ALL_TOOLS = [
datasets: {
type: 'object',
description:
'dataset 授权,例如 { registrations: { insert: true, columns: { insert: ["name","phone"] } } }',
'dataset 授权,例如 { registrations: { insert: true, columns: { insert: ["name","phone"] }, rowPolicy: { scope: "own_rows" } } }',
},
defaultVisitorRole: {
type: 'string',
description: 'login_required 模式下未列名访问者的默认角色:deny、viewer、editor',
},
visitors: {
type: 'object',
description: '访问者角色映射,例如 { "user-2": "editor", "user-3": "viewer" }',
},
roles: {
type: 'object',
description: '可选角色权限覆盖,例如 { viewer: { read: true, insert: false } }',
},
},
required: ['pageId', 'accessMode', 'datasets'],
},
},
{
name: 'private_data_close_page_dataset',
description:
'关闭已发布页面的某个 dataset 公开访问能力。写入策略文件并将 read/insert/update/delete 全部关闭。',
inputSchema: {
type: 'object',
properties: {
pageId: { type: 'string', description: '页面 ID' },
dataset: { type: 'string', description: '要关闭的 dataset 名称' },
ownerUserId: { type: 'string', description: '页面 owner 用户 ID,可选,默认当前会话用户' },
},
required: ['pageId', 'dataset'],
},
},
];
let quotaPool = null;
@@ -554,12 +581,31 @@ async function callTool(name, args) {
ownerUserId,
accessMode: args.accessMode,
datasets: args.datasets,
defaultVisitorRole: args.defaultVisitorRole,
visitors: args.visitors,
roles: args.roles,
},
{ fallbackPageId: args.pageId, fallbackOwnerUserId: ownerUserId },
);
const saved = writePageAccessPolicy(SANDBOX, policy);
if (isQuotaSyncConfigured()) {
await upsertPageDataPolicyIndex(getQuotaPool(), saved).catch(() => null);
}
return [{ type: 'text', text: JSON.stringify(saved, null, 2) }];
}
case 'private_data_close_page_dataset': {
const ownerUserId = String(args.ownerUserId ?? PRIVATE_DATA_USER_ID ?? '').trim();
if (!ownerUserId) throw new Error('缺少 ownerUserId,无法关闭 dataset');
const policy = readPageAccessPolicy(SANDBOX, args.pageId);
if (!policy) throw new Error('页面数据策略不存在');
if (policy.ownerUserId !== ownerUserId) throw new Error('无权关闭该页面 dataset');
const closed = closePolicyDataset(policy, args.dataset);
const saved = writePageAccessPolicy(SANDBOX, closed);
if (isQuotaSyncConfigured()) {
await upsertPageDataPolicyIndex(getQuotaPool(), saved).catch(() => null);
}
return [{ type: 'text', text: JSON.stringify(saved.datasets[args.dataset], null, 2) }];
}
case 'schedule_create_item': {
const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai';
const startAt = resolveScheduleTimestamp({