import { assertSafeSqlIdentifier, normalizeDatasetConfig } from './user-data-space-service.mjs'; import { normalizeRowPolicy, normalizeRoleOverrides, normalizeVisitorAssignments, } from './page-access-visitor.mjs'; export const PAGE_DATA_ACCESS_MODES = new Set(['public', 'password', 'login_required']); export function normalizePolicyDataset(name, raw) { const datasetName = assertSafeSqlIdentifier(name, 'dataset 名称'); if (!raw || typeof raw !== 'object') { throw Object.assign(new Error(`dataset 策略无效:${datasetName}`), { code: 'invalid_policy' }); } const columns = raw.columns && typeof raw.columns === 'object' ? raw.columns : {}; const normalizedColumns = {}; for (const [action, fields] of Object.entries(columns)) { if (!Array.isArray(fields)) continue; normalizedColumns[action] = fields.map((field) => assertSafeSqlIdentifier(field, `${action} 字段`)); } return { name: datasetName, read: Boolean(raw.read), insert: Boolean(raw.insert), update: Boolean(raw.update), softDelete: Boolean(raw.softDelete ?? raw.soft_delete), // `delete` is the page-facing spelling for an intentional permanent PG // delete; retain hardDelete for existing policies. hardDelete: Boolean(raw.hardDelete ?? raw.hard_delete ?? raw.delete), columns: normalizedColumns, limits: raw.limits && typeof raw.limits === 'object' ? { maxRowsPerRead: Number(raw.limits.maxRowsPerRead ?? 100), maxInsertBytes: Number(raw.limits.maxInsertBytes ?? 8192), } : undefined, rowPolicy: normalizeRowPolicy(raw.rowPolicy ?? raw.row_policy), closed: Boolean(raw.closed), closedAt: raw.closedAt ?? raw.closed_at ?? null, }; } export function normalizePageAccessPolicy(raw, { fallbackPageId = null, fallbackOwnerUserId = null } = {}) { if (!raw || typeof raw !== 'object') { throw Object.assign(new Error('Page Access Policy 无效'), { code: 'invalid_policy' }); } const pageId = String(raw.pageId ?? fallbackPageId ?? '').trim(); const ownerUserId = String(raw.ownerUserId ?? fallbackOwnerUserId ?? '').trim(); if (!pageId) throw Object.assign(new Error('缺少 pageId'), { code: 'invalid_policy' }); if (!ownerUserId) throw Object.assign(new Error('缺少 ownerUserId'), { code: 'invalid_policy' }); const accessMode = String(raw.accessMode ?? raw.access_mode ?? 'public').trim(); if (!PAGE_DATA_ACCESS_MODES.has(accessMode)) { throw Object.assign(new Error(`不支持的 accessMode:${accessMode}`), { code: 'invalid_policy' }); } const datasetsInput = raw.datasets && typeof raw.datasets === 'object' ? raw.datasets : {}; const datasets = {}; for (const [name, config] of Object.entries(datasetsInput)) { datasets[assertSafeSqlIdentifier(name, 'dataset 名称')] = normalizePolicyDataset(name, config); } const defaultVisitorRole = String( raw.defaultVisitorRole ?? raw.default_visitor_role ?? (accessMode === 'login_required' ? 'viewer' : 'deny'), ).trim(); if (!['deny', 'viewer', 'editor'].includes(defaultVisitorRole)) { throw Object.assign(new Error(`不支持的 defaultVisitorRole:${defaultVisitorRole}`), { code: 'invalid_policy', }); } return { pageId, ownerUserId, workspaceRef: String(raw.workspaceRef ?? raw.workspace_ref ?? '').trim() || null, accessMode, defaultVisitorRole, visitors: normalizeVisitorAssignments(raw.visitors), roles: normalizeRoleOverrides(raw.roles), datasets, updatedAt: raw.updatedAt ?? new Date().toISOString(), }; } export function buildEffectiveDataset(registryDataset, policyDataset) { if (!registryDataset || !policyDataset) return null; const actions = []; if (policyDataset.read) actions.push('read'); if (policyDataset.insert) actions.push('insert'); if (policyDataset.update) actions.push('update'); if (policyDataset.softDelete) actions.push('soft_delete'); if (policyDataset.hardDelete) actions.push('hard_delete'); 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 normalizeDatasetConfig({ name: registryDataset.name, table: registryDataset.table, description: registryDataset.description, actions, columns, limits: policyDataset.limits ?? registryDataset.limits, }); } export async function assertPageAccessPolicyMatchesRegistry(policy, userDataSpace) { if (!userDataSpace?.getDataset) { throw Object.assign(new Error('缺少 Page Data 数据空间'), { code: 'feature_disabled', status: 503, }); } const actionFlags = [ ['read', 'read'], ['insert', 'insert'], ['update', 'update'], ['softDelete', 'soft_delete'], ['hardDelete', 'hard_delete'], ]; const columnGroups = ['read', 'insert', 'update', 'soft_delete']; for (const [datasetName, policyDataset] of Object.entries(policy?.datasets ?? {})) { const registryDataset = await userDataSpace.getDataset(datasetName); if (!registryDataset) { throw Object.assign(new Error(`dataset 未注册:${datasetName}`), { code: 'dataset_not_found', status: 404, datasetName, }); } const tableColumns = await userDataSpace.listTableColumns?.(registryDataset.table); if (!Array.isArray(tableColumns) || tableColumns.length === 0) { throw Object.assign(new Error(`dataset 对应表不存在:${registryDataset.table}`), { code: 'table_not_found', status: 404, datasetName, table: registryDataset.table, }); } const actualColumnNames = new Set(tableColumns.map((column) => column.name)); const missingRegistryColumns = [ ...new Set(Object.values(registryDataset.columns ?? {}).flat()), ].filter((column) => !actualColumnNames.has(column)); if (missingRegistryColumns.length) { throw Object.assign( new Error( `dataset「${datasetName}」注册字段不存在:${missingRegistryColumns.join(', ')}`, ), { code: 'dataset_schema_mismatch', status: 409, datasetName, columns: missingRegistryColumns, }, ); } const registeredActions = new Set(registryDataset.actions ?? []); for (const [policyFlag, registryAction] of actionFlags) { if (policyDataset?.[policyFlag] && !registeredActions.has(registryAction)) { throw Object.assign( new Error(`dataset「${datasetName}」未注册 ${registryAction} 权限`), { code: 'action_not_allowed', status: 403, datasetName, action: registryAction, }, ); } } for (const action of columnGroups) { const requestedColumns = policyDataset?.columns?.[action]; if (!Array.isArray(requestedColumns)) continue; const registeredColumns = new Set(registryDataset.columns?.[action] ?? []); const unregisteredColumns = requestedColumns.filter( (column) => !registeredColumns.has(column), ); if (unregisteredColumns.length) { throw Object.assign( new Error( `dataset「${datasetName}」的 ${action} 字段未注册:${unregisteredColumns.join(', ')}`, ), { code: 'columns_not_allowed', status: 403, datasetName, action, columns: unregisteredColumns, }, ); } } } return policy; } export function defaultDatasetsForAccessMode(accessMode, datasets = {}) { const normalized = {}; for (const [name, config] of Object.entries(datasets)) { const base = normalizePolicyDataset(name, config); if (accessMode === 'public') { normalized[name] = { ...base, read: false, update: false, softDelete: false, hardDelete: false, }; } else if (accessMode === 'password') { normalized[name] = { ...base, hardDelete: false, }; } else { normalized[name] = { ...base, hardDelete: false, }; } } return normalized; } export function policyAllowsAction(policy, datasetName, action) { const dataset = policy?.datasets?.[datasetName]; if (!dataset) return false; if (dataset.closed) return false; if (action === 'read') return dataset.read; if (action === 'insert') return dataset.insert; if (action === 'update') return dataset.update; if (action === 'soft_delete') return dataset.softDelete; if (action === 'hard_delete') return dataset.hardDelete; return false; } export function closePolicyDataset(policy, datasetName) { const dataset = policy?.datasets?.[datasetName]; if (!dataset) { throw Object.assign(new Error('dataset 未授权'), { code: 'dataset_not_found' }); } return { ...policy, datasets: { ...policy.datasets, [datasetName]: { ...dataset, read: false, insert: false, update: false, softDelete: false, hardDelete: false, closed: true, closedAt: new Date().toISOString(), }, }, updatedAt: new Date().toISOString(), }; } export function mapPublicationAccessModeToPageData(accessMode) { const mode = String(accessMode ?? '').trim(); if (PAGE_DATA_ACCESS_MODES.has(mode)) return mode; return null; } export function buildDatasetPolicyFromRegistry( registryDataset, { read = false, insert = false, update = false, softDelete = false, accessMode = 'public' } = {}, ) { const rowPolicy = accessMode === 'login_required' && (update || softDelete || read) ? { scope: 'own_rows', ownerColumn: 'created_by_user_id' } : null; return normalizePolicyDataset(registryDataset?.name ?? 'dataset', { read: Boolean(read), insert: Boolean(insert), update: Boolean(update), softDelete: Boolean(softDelete), columns: { read: registryDataset?.columns?.read ?? [], insert: registryDataset?.columns?.insert ?? [], update: registryDataset?.columns?.update ?? [], soft_delete: registryDataset?.columns?.soft_delete ?? ['id'], }, rowPolicy, }); } export function buildPageDataPolicyFromPublishInput({ pageId, ownerUserId, accessMode, datasetName, registryDataset, capabilities = {}, }) { const mappedAccessMode = mapPublicationAccessModeToPageData(accessMode); if (!mappedAccessMode) { throw Object.assign(new Error('当前发布访问模式不支持 Page Data'), { code: 'invalid_policy' }); } const defaults = mappedAccessMode === 'public' ? { read: false, insert: true, update: false, softDelete: false } : mappedAccessMode === 'password' ? { read: true, insert: true, update: false, softDelete: false } : { read: true, insert: true, update: true, softDelete: false }; const datasetPolicy = buildDatasetPolicyFromRegistry(registryDataset, { read: capabilities.read ?? defaults.read, insert: capabilities.insert ?? defaults.insert, update: capabilities.update ?? defaults.update, softDelete: capabilities.softDelete ?? defaults.softDelete, accessMode: mappedAccessMode, }); return normalizePageAccessPolicy( { pageId, ownerUserId, accessMode: mappedAccessMode, datasets: { [datasetName]: datasetPolicy, }, }, { fallbackPageId: pageId, fallbackOwnerUserId: ownerUserId }, ); }