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
+272
View File
@@ -0,0 +1,272 @@
/**
* Page Data API 第一版验收(对应 docs/architecture/page-data-api-public-access-plan-20260708.md §14
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import express from 'express';
import { attachPageDataRoutes } from './page-data-routes.mjs';
import { createPageDataService } from './page-data-service.mjs';
import { createPageDataPublicService } from './page-data-public-service.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
import { writePageAccessPolicy } from './page-data-policy-store.mjs';
const PAGE_ID = 'page-acceptance-1';
const OWNER_ID = 'user-acceptance-1';
function createPool(accessMode = 'public') {
return {
async query(sql, params) {
if (sql.includes('FROM h5_publish_records')) {
return [
[
{
id: 'pub-acc-1',
user_id: OWNER_ID,
page_id: PAGE_ID,
access_mode: accessMode,
password_hash: null,
status: 'online',
expires_at: null,
},
],
];
}
if (sql.includes('h5_page_data_policy_index')) {
return [[]];
}
return [[]];
},
};
}
async function setupWorkspace(workspaceRoot) {
const service = createUserDataSpaceService({ workspaceRoot });
await service.executeSql(`CREATE TABLE leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
note TEXT,
secret_field TEXT,
created_at TEXT,
deleted_at TEXT,
deleted_by TEXT
);`);
await service.upsertDataset({
name: 'leads',
table: 'leads',
description: '线索',
actions: ['read', 'insert', 'update', 'soft_delete'],
columns: {
read: ['id', 'name', 'note', 'created_at'],
insert: ['name', 'note'],
update: ['note'],
soft_delete: ['id'],
},
});
return service;
}
function buildApp(workspaceRoot, pool = createPool('public')) {
const pageDataPublicService = createPageDataPublicService({
getPool: () => pool,
resolveWorkspaceRootForOwner: () => workspaceRoot,
});
const api = express.Router();
api.use(express.json({ limit: '32kb' }));
api.use((req, _res, next) => {
if (req.headers['x-test-user']) {
req.currentUser = { id: OWNER_ID, workspaceRoot };
}
next();
});
attachPageDataRoutes(api, {
sendData: (res, _req, data, status = 200) => res.status(status).json({ data }),
sendError: (res, _req, status, code, message) =>
res.status(status).json({ error: { code, message } }),
getPageDataService: () =>
createPageDataService({
getPool: () => pool,
resolveWorkspaceRoot: async () => workspaceRoot,
}),
getPageDataPublicService: () => pageDataPublicService,
});
const app = express();
app.set('trust proxy', true);
app.use('/api', api);
return app;
}
async function request(app, method, url, { body, headers } = {}) {
const server = app.listen(0);
try {
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}${url}`, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: response.status, body: await response.json() };
} finally {
await new Promise((resolve) => server.close(resolve));
}
}
test('acceptance: agent dataset registry + owner read/insert via Page Data API', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-owner-'));
await setupWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
const datasets = await request(app, 'GET', '/api/page-data', {
headers: { 'x-test-user': '1' },
});
assert.equal(datasets.status, 200);
assert.equal(datasets.body.data.datasets[0].name, 'leads');
const inserted = await request(app, 'POST', '/api/page-data/leads/rows', {
headers: { 'x-test-user': '1' },
body: { name: 'Owner 线索', note: '内部' },
});
assert.equal(inserted.status, 201);
const listed = await request(app, 'GET', '/api/page-data/leads?limit=10', {
headers: { 'x-test-user': '1' },
});
assert.equal(listed.status, 200);
assert.equal(listed.body.data.rows.length, 1);
});
test('acceptance: public insert only when authorized; read/update/delete denied by default', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-public-'));
await setupWorkspace(workspaceRoot);
writePageAccessPolicy(workspaceRoot, {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'public',
datasets: {
leads: {
insert: true,
columns: { insert: ['name', 'note'] },
},
},
});
const app = buildApp(workspaceRoot);
const ok = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, {
body: { name: '公开线索' },
});
assert.equal(ok.status, 201);
const readDenied = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads`);
assert.equal(readDenied.status, 403);
assert.equal(readDenied.body.error.code, 'action_not_allowed');
const updateDenied = await request(app, 'PATCH', `/api/public/pages/${PAGE_ID}/data/leads/rows/1`, {
body: { note: 'hack' },
});
assert.equal(updateDenied.status, 403);
const deleteDenied = await request(app, 'DELETE', `/api/public/pages/${PAGE_ID}/data/leads/rows/1`);
assert.equal(deleteDenied.status, 403);
});
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-'));
await setupWorkspace(workspaceRoot);
writePageAccessPolicy(workspaceRoot, {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'public',
datasets: {
leads: { insert: true, columns: { insert: ['name'] } },
},
});
const app = buildApp(workspaceRoot);
const unknownDataset = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/unknown/rows`, {
body: { name: 'x' },
});
assert.equal(unknownDataset.status, 403);
const badColumn = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, {
body: { name: 'x', secret_field: 'leak' },
});
assert.equal(badColumn.status, 403);
assert.equal(badColumn.body.error.code, 'columns_not_allowed');
const sqlKey = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, {
body: { name: 'x', sql: 'DROP TABLE leads' },
});
assert.equal(sqlKey.status, 403);
});
test('acceptance: apply-publish creates policy for published page', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-publish-'));
await setupWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
const applied = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/apply-publish`, {
headers: { 'x-test-user': '1' },
body: {
datasetName: 'leads',
capabilities: { read: false, insert: true, update: false, softDelete: false },
},
});
assert.equal(applied.status, 200);
assert.equal(applied.body.data.policy.datasets.leads.insert, true);
assert.equal(applied.body.data.policy.datasets.leads.read, false);
const publicInsert = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, {
body: { name: '发布后提交' },
});
assert.equal(publicInsert.status, 201);
});
test('acceptance: turnstile enforced for public insert when secret configured', async () => {
const previousSecret = process.env.PAGE_DATA_TURNSTILE_SECRET;
const previousFetch = globalThis.fetch;
process.env.PAGE_DATA_TURNSTILE_SECRET = 'acceptance-secret';
globalThis.fetch = async (url, init) => {
if (String(url).includes('challenges.cloudflare.com')) {
return {
ok: true,
async json() {
return { success: true };
},
};
}
return previousFetch(url, init);
};
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-captcha-'));
await setupWorkspace(workspaceRoot);
writePageAccessPolicy(workspaceRoot, {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'public',
datasets: { leads: { insert: true, columns: { insert: ['name'] } } },
});
const app = buildApp(workspaceRoot);
try {
const denied = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, {
body: { name: '无验证码' },
});
assert.equal(denied.status, 400);
assert.equal(denied.body.error.code, 'captcha_required');
const ok = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, {
headers: { 'x-page-data-captcha': 'token-ok' },
body: { name: '有验证码' },
});
assert.equal(ok.status, 201);
} finally {
globalThis.fetch = previousFetch;
if (previousSecret == null) delete process.env.PAGE_DATA_TURNSTILE_SECRET;
else process.env.PAGE_DATA_TURNSTILE_SECRET = previousSecret;
}
});