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
+113
View File
@@ -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 },
);
}