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:
@@ -1,4 +1,9 @@
|
||||
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']);
|
||||
|
||||
@@ -28,6 +33,9 @@ export function normalizePolicyDataset(name, raw) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,11 +56,24 @@ export function normalizePageAccessPolicy(raw, { fallbackPageId = null, fallback
|
||||
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(),
|
||||
};
|
||||
@@ -112,6 +133,7 @@ export function defaultDatasetsForAccessMode(accessMode, datasets = {}) {
|
||||
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;
|
||||
@@ -119,3 +141,94 @@ export function policyAllowsAction(policy, datasetName, action) {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user