Files
memind/page-access-policy.mjs
john 6b0c633a75 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>
2026-07-08 14:52:49 +08:00

235 lines
8.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
hardDelete: Boolean(raw.hardDelete ?? raw.hard_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 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 },
);
}