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
+149
View File
@@ -0,0 +1,149 @@
import { assertSafeSqlIdentifier } from './user-data-space-service.mjs';
export const PAGE_DATA_VISITOR_ROLES = new Set(['viewer', 'editor', 'owner']);
export const DEFAULT_ROLE_PERMISSIONS = {
viewer: { read: true, insert: false, update: false, softDelete: false },
editor: { read: true, insert: true, update: true, softDelete: true },
owner: { read: true, insert: true, update: true, softDelete: true, hardDelete: false },
};
export function normalizeRowPolicy(raw) {
if (!raw || typeof raw !== 'object') return null;
const scope = String(raw.scope ?? 'all_rows').trim();
if (!['all_rows', 'own_rows', 'owner_only'].includes(scope)) {
throw Object.assign(new Error(`不支持的 rowPolicy.scope${scope}`), { code: 'invalid_policy' });
}
const ownerColumn = assertSafeSqlIdentifier(
raw.ownerColumn ?? raw.owner_column ?? 'created_by_user_id',
'rowPolicy.ownerColumn',
);
return { scope, ownerColumn };
}
export function normalizeVisitorAssignments(raw) {
if (!raw || typeof raw !== 'object') return {};
const visitors = {};
for (const [userId, value] of Object.entries(raw)) {
const visitorId = String(userId ?? '').trim();
if (!visitorId) continue;
const role = typeof value === 'string' ? value.trim() : String(value?.role ?? '').trim();
if (!PAGE_DATA_VISITOR_ROLES.has(role) || role === 'owner') {
throw Object.assign(new Error(`访问者角色无效:${role}`), { code: 'invalid_policy' });
}
visitors[visitorId] = role;
}
return visitors;
}
export function normalizeRoleOverrides(raw) {
if (!raw || typeof raw !== 'object') return {};
const roles = {};
for (const [roleName, permissions] of Object.entries(raw)) {
const role = String(roleName ?? '').trim();
if (!PAGE_DATA_VISITOR_ROLES.has(role)) {
throw Object.assign(new Error(`角色定义无效:${role}`), { code: 'invalid_policy' });
}
roles[role] = {
read: Boolean(permissions?.read),
insert: Boolean(permissions?.insert),
update: Boolean(permissions?.update),
softDelete: Boolean(permissions?.softDelete ?? permissions?.soft_delete),
hardDelete: Boolean(permissions?.hardDelete ?? permissions?.hard_delete),
};
}
return roles;
}
export function resolveVisitorRole(policy, visitorUserId) {
const visitorId = String(visitorUserId ?? '').trim();
if (!visitorId) return null;
if (visitorId === policy.ownerUserId) return 'owner';
const assigned = policy.visitors?.[visitorId];
if (assigned) return assigned;
const defaultRole = String(policy.defaultVisitorRole ?? 'deny').trim();
if (defaultRole === 'deny') return null;
if (!PAGE_DATA_VISITOR_ROLES.has(defaultRole)) return null;
return defaultRole;
}
export function resolveRolePermissions(policy, role) {
const defaults = DEFAULT_ROLE_PERMISSIONS[role] ?? DEFAULT_ROLE_PERMISSIONS.viewer;
const overrides = policy.roles?.[role];
if (!overrides) return { ...defaults };
return {
read: overrides.read ?? defaults.read,
insert: overrides.insert ?? defaults.insert,
update: overrides.update ?? defaults.update,
softDelete: overrides.softDelete ?? defaults.softDelete,
hardDelete: overrides.hardDelete ?? defaults.hardDelete,
};
}
export function roleAllowsAction(rolePermissions, action) {
if (!rolePermissions) return false;
if (action === 'read') return Boolean(rolePermissions.read);
if (action === 'insert') return Boolean(rolePermissions.insert);
if (action === 'update') return Boolean(rolePermissions.update);
if (action === 'soft_delete') return Boolean(rolePermissions.softDelete);
if (action === 'hard_delete') return Boolean(rolePermissions.hardDelete);
return false;
}
export function buildEffectiveDatasetForRole(registryDataset, policyDataset, rolePermissions) {
if (!registryDataset || !policyDataset || !rolePermissions) return null;
const actions = [];
if (policyDataset.read && rolePermissions.read) actions.push('read');
if (policyDataset.insert && rolePermissions.insert) actions.push('insert');
if (policyDataset.update && rolePermissions.update) actions.push('update');
if (policyDataset.softDelete && rolePermissions.softDelete) actions.push('soft_delete');
if (policyDataset.hardDelete && rolePermissions.hardDelete) actions.push('hard_delete');
if (!actions.length) return null;
const columns = {
read: policyDataset.columns?.read ?? registryDataset.columns?.read ?? [],
insert: policyDataset.columns?.insert ?? registryDataset.columns?.insert ?? [],
update: policyDataset.columns?.update ?? registryDataset.columns?.update ?? [],
soft_delete: policyDataset.columns?.soft_delete ?? registryDataset.columns?.soft_delete ?? ['id'],
};
return {
name: registryDataset.name,
table: registryDataset.table,
description: registryDataset.description,
actions,
columns,
limits: policyDataset.limits ?? registryDataset.limits,
rowPolicy: policyDataset.rowPolicy ?? null,
};
}
export function resolveRowAccessScope(rowPolicy, {
visitorRole,
visitorUserId,
ownerUserId,
tableColumns = [],
} = {}) {
if (!rowPolicy || rowPolicy.scope === 'all_rows') {
return { bypass: true, whereClause: null, ownerColumn: null, visitorUserId };
}
const isOwner = visitorRole === 'owner' || visitorUserId === ownerUserId;
if (rowPolicy.scope === 'owner_only') {
if (isOwner) return { bypass: true, whereClause: null, ownerColumn: null, visitorUserId };
throw Object.assign(new Error('当前数据集仅页面所有者可访问'), { code: 'row_not_allowed' });
}
if (rowPolicy.scope === 'own_rows') {
if (isOwner) return { bypass: true, whereClause: null, ownerColumn: rowPolicy.ownerColumn, visitorUserId };
const column = rowPolicy.ownerColumn;
const hasColumn = tableColumns.some((item) => item.name === column);
if (!hasColumn) {
throw Object.assign(new Error(`行级权限列不存在:${column}`), { code: 'row_policy_invalid' });
}
const safeVisitorId = String(visitorUserId ?? '').replace(/'/g, "''");
return {
bypass: false,
whereClause: `"${column}" = '${safeVisitorId}'`,
ownerColumn: column,
visitorUserId,
};
}
return { bypass: true, whereClause: null, ownerColumn: null, visitorUserId };
}