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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user