feat(page-data): add private and public dataset API (phase 1-3)
Extract UserDataSpaceService for shared SQLite access, wire logged-in Page Data routes, and add public insert plus password-token read/update/delete with policy storage, rate limits, and regression tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { assertSafeSqlIdentifier, normalizeDatasetConfig } from './user-data-space-service.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,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return {
|
||||
pageId,
|
||||
ownerUserId,
|
||||
workspaceRef: String(raw.workspaceRef ?? raw.workspace_ref ?? '').trim() || null,
|
||||
accessMode,
|
||||
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 (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;
|
||||
}
|
||||
Reference in New Issue
Block a user