feat(page-data): owner CRUD, public read, and role policy UI

Expose owner PATCH/soft-delete routes, allow anonymous read/stats on
public pages when policy enables read, and add login_required role
editor to the ops panel with tests and doc updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-08 14:54:14 +08:00
parent 6b0c633a75
commit 8d629e7a4e
11 changed files with 360 additions and 9 deletions
+5 -6
View File
@@ -23,11 +23,10 @@
```text ```text
GET /api/page-data # 列出已注册 dataset GET /api/page-data # 列出已注册 dataset
GET /api/page-data/:dataset # 读行 GET /api/page-data/:dataset
POST /api/page-data/:dataset/rows # 插入 POST /api/page-data/:dataset/rows
GET /api/page-data/:dataset/schema PATCH /api/page-data/:dataset/rows/:id
GET /api/page-data/:dataset/stats DELETE /api/page-data/:dataset/rows/:id # soft delete
GET /api/page-data/:dataset/export?format=json|csv
POST /api/page-data/:dataset/rows/:id/restore POST /api/page-data/:dataset/rows/:id/restore
GET /api/page-data/policies GET /api/page-data/policies
@@ -53,7 +52,7 @@ GET /api/public/pages/:pageId/data/:dataset/schema
GET /api/public/pages/:pageId/data/:dataset/stats GET /api/public/pages/:pageId/data/:dataset/stats
``` ```
公开请求可带 `x-page-data-token`(口令/登录会话)。**公开页不能直接传 SQL**,只能提交策略白名单字段 公开请求可带 `x-page-data-token`(口令/登录会话)。**完全公开**模式下:默认仅 insert;若策略显式开启 `read`,匿名访问者可读白名单字段并查看 stats
## HTML 页面嵌入 ## HTML 页面嵌入
+28
View File
@@ -174,6 +174,34 @@ test('acceptance: public insert only when authorized; read/update/delete denied
assert.equal(deleteDenied.status, 403); assert.equal(deleteDenied.status, 403);
}); });
test('acceptance: public read and stats when policy enables read', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-public-read-'));
const ownerService = await setupWorkspace(workspaceRoot);
await ownerService.insertDatasetRow('leads', { name: '展示项', note: 'secret' });
writePageAccessPolicy(workspaceRoot, {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'public',
datasets: {
leads: {
read: true,
columns: { read: ['id', 'name', 'created_at'] },
},
},
});
const app = buildApp(workspaceRoot);
const listed = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads?limit=10`);
assert.equal(listed.status, 200);
assert.equal(listed.body.data.rows.length, 1);
assert.equal(listed.body.data.rows[0].name, '展示项');
assert.equal(listed.body.data.rows[0].note, undefined);
const stats = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads/stats`);
assert.equal(stats.status, 200);
assert.equal(stats.body.data.total, 1);
});
test('acceptance: unauthorized dataset/action/column and SQL-like keys are rejected', async () => { test('acceptance: unauthorized dataset/action/column and SQL-like keys are rejected', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-deny-')); const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-deny-'));
await setupWorkspace(workspaceRoot); await setupWorkspace(workspaceRoot);
+9 -1
View File
@@ -344,6 +344,14 @@ test('integration: apply-publish route binds dataset after publication', async (
assert.equal(applied.status, 200); assert.equal(applied.status, 200);
assert.equal(applied.body.data.policy.datasets.signups.read, true); assert.equal(applied.body.data.policy.datasets.signups.read, true);
const publicReadEmpty = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`);
assert.equal(publicReadEmpty.status, 200);
assert.equal(publicReadEmpty.body.data.rows.length, 0);
await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, {
body: { name: '发布后公开读', phone: '13800000000' },
});
const publicRead = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`); const publicRead = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`);
assert.equal(publicRead.status, 403); assert.equal(publicRead.status, 200);
assert.equal(publicRead.body.data.rows.length, 1);
}); });
+7 -1
View File
@@ -209,9 +209,15 @@ export function createPageDataPublicService(deps = {}) {
function resolveAccessContext({ publication, policy, req, action, datasetName }) { function resolveAccessContext({ publication, policy, req, action, datasetName }) {
const accessMode = publication.access_mode; const accessMode = publication.access_mode;
if (accessMode === 'public') { if (accessMode === 'public') {
if (action === 'read' || action === 'update' || action === 'soft_delete') { if (action === 'update' || action === 'soft_delete') {
throw Object.assign(new Error('当前页面未开放此数据操作'), { code: 'action_not_allowed' }); throw Object.assign(new Error('当前页面未开放此数据操作'), { code: 'action_not_allowed' });
} }
if (action === 'read') {
if (!policyAllowsAction(policy, datasetName, 'read')) {
throw Object.assign(new Error('当前页面未开放此数据操作'), { code: 'action_not_allowed' });
}
return { accessMode, session: null };
}
if (!policyAllowsAction(policy, datasetName, action)) { if (!policyAllowsAction(policy, datasetName, action)) {
throw Object.assign(new Error(`dataset 未授权 ${action}`), { code: 'action_not_allowed' }); throw Object.assign(new Error(`dataset 未授权 ${action}`), { code: 'action_not_allowed' });
} }
+13
View File
@@ -111,6 +111,19 @@ test('public page read is denied in public access mode by default', async () =>
); );
}); });
test('public page read works when policy explicitly enables read', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-read-ok-'));
const ownerService = await setupPublicWorkspace(workspaceRoot, { accessMode: 'public', withRead: true });
await ownerService.insertRowForDataset(ownerService.getDataset('signups'), {
name: '公开可读',
phone: '13900000002',
});
const service = createPublicService(workspaceRoot, { accessMode: 'public' });
const rows = await service.listRows(PAGE_ID, 'signups', { headers: {} });
assert.equal(rows.rows.length, 1);
assert.equal(rows.rows[0].name, '公开可读');
});
test('login_required page read requires login and works for authenticated visitor', async () => { test('login_required page read requires login and works for authenticated visitor', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-login-required-')); const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-login-required-'));
const ownerService = await setupPublicWorkspace(workspaceRoot, { const ownerService = await setupPublicWorkspace(workspaceRoot, {
+30
View File
@@ -286,6 +286,36 @@ export function attachPageDataRoutes(api, deps) {
} }
}); });
api.patch('/page-data/:dataset/rows/:rowId', async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const service = getPageDataService?.();
if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
const payload = parseRowPayload(req, { stripMeta: true });
if (!payload) return sendError(res, req, 400, 'invalid_request', '更新数据必须是 JSON 对象');
try {
const result = await service.updateRow(user, req.params.dataset, req.params.rowId, payload);
return sendData(res, req, { dataset: result.dataset.name, row: result.row });
} catch (error) {
const status = error?.status ?? 400;
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '更新失败');
}
});
api.delete('/page-data/:dataset/rows/:rowId', async (req, res) => {
const user = requireUser(req, res, sendError);
if (!user) return;
const service = getPageDataService?.();
if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
try {
const result = await service.softDeleteRow(user, req.params.dataset, req.params.rowId);
return sendData(res, req, { dataset: result.dataset.name, row: result.row, deleted: true });
} catch (error) {
const status = error?.status ?? 400;
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '删除失败');
}
});
api.post('/public/pages/:pageId/data-auth', async (req, res) => { api.post('/public/pages/:pageId/data-auth', async (req, res) => {
const publicService = getPageDataPublicService?.(); const publicService = getPageDataPublicService?.();
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
+48
View File
@@ -101,6 +101,54 @@ test('page data routes allow logged-in owner to read and insert dataset rows', a
assert.equal(stats.body.data.total, 1); assert.equal(stats.body.data.total, 1);
}); });
test('page data routes allow owner update and soft delete', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-crud-'));
const service = createUserDataSpaceService({ workspaceRoot });
await service.executeSql(`CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT DEFAULT 'open',
created_at TEXT,
deleted_at TEXT,
deleted_by TEXT,
updated_at TEXT
);`);
await service.upsertDataset({
name: 'tasks',
table: 'tasks',
actions: ['read', 'insert', 'update', 'soft_delete'],
columns: {
read: ['id', 'title', 'status', 'created_at'],
insert: ['title'],
update: ['status'],
soft_delete: ['id'],
},
});
const app = createApiApp({
workspaceRoot,
user: { id: 'user-1', workspaceRoot },
});
const inserted = await requestJson(app, 'POST', '/api/page-data/tasks/rows', { title: '待办' });
assert.equal(inserted.status, 201);
const rowId = inserted.body.data.row.id;
const updated = await requestJson(app, 'PATCH', `/api/page-data/tasks/rows/${rowId}`, {
status: 'done',
});
assert.equal(updated.status, 200);
assert.equal(updated.body.data.row.status, 'done');
const deleted = await requestJson(app, 'DELETE', `/api/page-data/tasks/rows/${rowId}`);
assert.equal(deleted.status, 200);
assert.equal(deleted.body.data.deleted, true);
const listed = await requestJson(app, 'GET', '/api/page-data/tasks?limit=10');
assert.equal(listed.status, 200);
assert.equal(listed.body.data.rows.length, 0);
});
test('page data routes reject unauthorized dataset action', async () => { test('page data routes reject unauthorized dataset action', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-deny-')); const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-deny-'));
const service = createUserDataSpaceService({ workspaceRoot }); const service = createUserDataSpaceService({ workspaceRoot });
+20
View File
@@ -97,6 +97,24 @@ export function createPageDataService(deps = {}) {
} }
} }
async function updateRow(user, datasetName, rowId, payload) {
const { service } = await createServiceForUser(user);
try {
return service.updateDatasetRow(datasetName, rowId, payload);
} catch (error) {
throw mapServiceError(error);
}
}
async function softDeleteRow(user, datasetName, rowId) {
const { service } = await createServiceForUser(user);
try {
return service.softDeleteDatasetRow(datasetName, rowId, { deletedBy: user.id });
} catch (error) {
throw mapServiceError(error);
}
}
async function restoreRow(user, datasetName, rowId) { async function restoreRow(user, datasetName, rowId) {
const { service } = await createServiceForUser(user); const { service } = await createServiceForUser(user);
try { try {
@@ -164,6 +182,8 @@ export function createPageDataService(deps = {}) {
getSchema, getSchema,
getStats, getStats,
insertRow, insertRow,
updateRow,
softDeleteRow,
restoreRow, restoreRow,
exportDataset, exportDataset,
listDatasets, listDatasets,
+148 -1
View File
@@ -6,9 +6,30 @@ import {
resetPageDataPassword, resetPageDataPassword,
restorePageDataRow, restorePageDataRow,
revokePageDataTokens, revokePageDataTokens,
savePageDataPolicy,
} from '../api/client'; } from '../api/client';
import type { MindSpacePublishCheck, PageDataOpsOverview } from '../types'; import type { MindSpacePublishCheck, PageDataOpsOverview } from '../types';
type RowPolicyScope = 'all_rows' | 'own_rows' | 'owner_only';
function formatVisitorsText(visitors: Record<string, 'viewer' | 'editor'> | undefined) {
return Object.entries(visitors ?? {})
.map(([userId, role]) => `${userId} ${role}`)
.join('\n');
}
function parseVisitorsText(text: string) {
const visitors: Record<string, 'viewer' | 'editor'> = {};
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^(\S+)[\s:]+(viewer|editor)$/i);
if (!match) continue;
visitors[match[1]] = match[2].toLowerCase() as 'viewer' | 'editor';
}
return visitors;
}
type Props = { type Props = {
pageId: string; pageId: string;
pageTitle: string; pageTitle: string;
@@ -29,6 +50,24 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
const [busyAction, setBusyAction] = useState<string | null>(null); const [busyAction, setBusyAction] = useState<string | null>(null);
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [defaultVisitorRole, setDefaultVisitorRole] = useState<'deny' | 'viewer' | 'editor'>('deny');
const [visitorsText, setVisitorsText] = useState('');
const [rowPolicies, setRowPolicies] = useState<
Record<string, { scope: RowPolicyScope; ownerColumn: string }>
>({});
const syncRoleForm = useCallback((policy: PageDataOpsOverview['policy']) => {
setDefaultVisitorRole(policy.defaultVisitorRole ?? 'deny');
setVisitorsText(formatVisitorsText(policy.visitors));
const next: Record<string, { scope: RowPolicyScope; ownerColumn: string }> = {};
for (const [name, dataset] of Object.entries(policy.datasets ?? {})) {
next[name] = {
scope: dataset.rowPolicy?.scope ?? 'all_rows',
ownerColumn: dataset.rowPolicy?.ownerColumn ?? 'created_by_user_id',
};
}
setRowPolicies(next);
}, []);
const loadOverview = useCallback(async () => { const loadOverview = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -36,6 +75,7 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
try { try {
const next = await getPageDataOpsOverview(pageId); const next = await getPageDataOpsOverview(pageId);
setOverview(next); setOverview(next);
syncRoleForm(next.policy);
} catch (err) { } catch (err) {
const text = err instanceof Error ? err.message : '加载页面数据运维信息失败'; const text = err instanceof Error ? err.message : '加载页面数据运维信息失败';
if (text.includes('policy_not_found') || text.includes('策略未配置')) { if (text.includes('policy_not_found') || text.includes('策略未配置')) {
@@ -47,7 +87,7 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [pageId]); }, [pageId, syncRoleForm]);
useEffect(() => { useEffect(() => {
void loadOverview(); void loadOverview();
@@ -129,6 +169,113 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
</div> </div>
) : null} ) : null}
{overview.publication.accessMode === 'login_required' ? (
<div className="mindspace-page-data-ops-section mindspace-page-data-ops-role-editor">
<h4>访 · </h4>
<label>
访
<select
value={defaultVisitorRole}
disabled={busyAction != null}
onChange={(event) =>
setDefaultVisitorRole(event.target.value as 'deny' | 'viewer' | 'editor')
}
>
<option value="deny"></option>
<option value="viewer"> (viewer)</option>
<option value="editor"> (editor)</option>
</select>
</label>
<label>
访 ID + + viewer/editor
<textarea
value={visitorsText}
disabled={busyAction != null}
rows={4}
placeholder={'user-a editor\nuser-b viewer'}
onChange={(event) => setVisitorsText(event.target.value)}
/>
</label>
{overview.datasets.length > 0 ? (
<div className="mindspace-page-data-ops-role-datasets">
<span>Dataset </span>
{overview.datasets.map((dataset) => (
<div key={dataset.name} className="mindspace-page-data-ops-role-dataset-row">
<code>{dataset.name}</code>
<select
value={rowPolicies[dataset.name]?.scope ?? 'all_rows'}
disabled={busyAction != null}
onChange={(event) =>
setRowPolicies((current) => ({
...current,
[dataset.name]: {
scope: event.target.value as RowPolicyScope,
ownerColumn: current[dataset.name]?.ownerColumn ?? 'created_by_user_id',
},
}))
}
>
<option value="all_rows"></option>
<option value="own_rows"></option>
<option value="owner_only"> owner</option>
</select>
{(rowPolicies[dataset.name]?.scope ?? 'all_rows') === 'own_rows' ? (
<input
value={rowPolicies[dataset.name]?.ownerColumn ?? 'created_by_user_id'}
disabled={busyAction != null}
placeholder="owner 列名"
onChange={(event) =>
setRowPolicies((current) => ({
...current,
[dataset.name]: {
scope: current[dataset.name]?.scope ?? 'own_rows',
ownerColumn: event.target.value,
},
}))
}
/>
) : null}
</div>
))}
</div>
) : null}
<button
type="button"
disabled={busyAction != null}
onClick={() =>
void runAction('save-role-policy', async () => {
if (!overview?.policy) return;
const datasets = { ...overview.policy.datasets };
for (const [name, rowPolicy] of Object.entries(rowPolicies)) {
if (!datasets[name]) continue;
datasets[name] = {
...datasets[name],
rowPolicy:
rowPolicy.scope === 'all_rows'
? undefined
: {
scope: rowPolicy.scope,
ownerColumn:
rowPolicy.scope === 'own_rows'
? rowPolicy.ownerColumn.trim() || 'created_by_user_id'
: undefined,
},
};
}
await savePageDataPolicy(pageId, {
defaultVisitorRole,
visitors: parseVisitorsText(visitorsText),
datasets,
});
setMessage('角色与行级策略已保存');
})
}
>
{busyAction === 'save-role-policy' ? '保存中…' : '保存角色策略'}
</button>
</div>
) : null}
<div className="mindspace-page-data-ops-section"> <div className="mindspace-page-data-ops-section">
<h4>Dataset </h4> <h4>Dataset </h4>
{overview.datasets.length === 0 ? ( {overview.datasets.length === 0 ? (
+34
View File
@@ -8410,6 +8410,40 @@ body,
font-size: 13px; font-size: 13px;
} }
.mindspace-page-data-ops-role-editor {
display: grid;
gap: 12px;
}
.mindspace-page-data-ops-role-editor label {
display: grid;
gap: 6px;
}
.mindspace-page-data-ops-role-editor textarea {
min-height: 88px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
}
.mindspace-page-data-ops-role-datasets {
display: grid;
gap: 8px;
}
.mindspace-page-data-ops-role-datasets > span {
color: rgba(24, 33, 29, 0.62);
font-size: 13px;
}
.mindspace-page-data-ops-role-dataset-row {
display: grid;
grid-template-columns: minmax(80px, 120px) 140px 1fr;
gap: 10px;
align-items: center;
}
.mindspace-page-data-ops-error { .mindspace-page-data-ops-error {
color: #9b2c2c; color: #9b2c2c;
} }
+18
View File
@@ -563,6 +563,22 @@ export function createUserDataSpaceService(options = {}) {
return { dataset, row }; return { dataset, row };
} }
async function updateDatasetRow(datasetName, rowId, payload, meta = {}) {
const dataset = getDataset(datasetName);
if (!dataset) {
throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' });
}
return updateRowForDataset(dataset, rowId, payload, meta);
}
async function softDeleteDatasetRow(datasetName, rowId, meta = {}) {
const dataset = getDataset(datasetName);
if (!dataset) {
throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' });
}
return softDeleteRowForDataset(dataset, rowId, meta);
}
async function insertDatasetRow(datasetName, payload, meta = {}) { async function insertDatasetRow(datasetName, payload, meta = {}) {
const dataset = getDataset(datasetName); const dataset = getDataset(datasetName);
if (!dataset) { if (!dataset) {
@@ -751,7 +767,9 @@ export function createUserDataSpaceService(options = {}) {
getStatsForDataset, getStatsForDataset,
insertDatasetRow, insertDatasetRow,
insertRowForDataset, insertRowForDataset,
updateDatasetRow,
updateRowForDataset, updateRowForDataset,
softDeleteDatasetRow,
softDeleteRowForDataset, softDeleteRowForDataset,
restoreSoftDeletedRowForDataset, restoreSoftDeletedRowForDataset,
restoreSoftDeletedRow, restoreSoftDeletedRow,