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:
john
2026-07-08 12:44:23 +08:00
parent 1c8e0db256
commit 8d01553469
17 changed files with 2587 additions and 189 deletions
+140
View File
@@ -0,0 +1,140 @@
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 { createUserDataSpaceService } from './user-data-space-service.mjs';
function createResponseRecorder() {
return {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(payload) {
this.body = payload;
return this;
},
};
}
function createApiApp({ workspaceRoot, user }) {
const api = express.Router();
api.use(express.json());
api.use((req, _res, next) => {
req.currentUser = user;
next();
});
const pageDataService = createPageDataService({
resolveWorkspaceRoot: async () => workspaceRoot,
});
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: () => pageDataService,
});
const app = express();
app.use('/api', api);
return app;
}
async function requestJson(app, method, url, body) {
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' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const payload = await response.json();
return { status: response.status, body: payload };
} finally {
await new Promise((resolve) => server.close(resolve));
}
}
test('page data routes allow logged-in owner to read and insert dataset rows', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-'));
const service = createUserDataSpaceService({ workspaceRoot });
await service.executeSql(`CREATE TABLE feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL,
created_at TEXT
);`);
await service.upsertDataset({
name: 'feedback',
table: 'feedback',
actions: ['read', 'insert'],
columns: {
read: ['id', 'message', 'created_at'],
insert: ['message'],
},
});
const app = createApiApp({
workspaceRoot,
user: { id: 'user-1', workspaceRoot },
});
const inserted = await requestJson(app, 'POST', '/api/page-data/feedback/rows', {
message: '很好用',
});
assert.equal(inserted.status, 201);
assert.equal(inserted.body.data.row.message, '很好用');
const listed = await requestJson(app, 'GET', '/api/page-data/feedback?limit=10');
assert.equal(listed.status, 200);
assert.equal(listed.body.data.rows.length, 1);
const schema = await requestJson(app, 'GET', '/api/page-data/feedback/schema');
assert.equal(schema.status, 200);
assert.equal(schema.body.data.dataset.name, 'feedback');
const stats = await requestJson(app, 'GET', '/api/page-data/feedback/stats');
assert.equal(stats.status, 200);
assert.equal(stats.body.data.total, 1);
});
test('page data routes reject unauthorized dataset action', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-deny-'));
const service = createUserDataSpaceService({ workspaceRoot });
await service.executeSql(`CREATE TABLE hidden (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secret TEXT NOT NULL
);`);
await service.upsertDataset({
name: 'hidden',
table: 'hidden',
actions: ['insert'],
columns: { insert: ['secret'] },
});
const app = createApiApp({
workspaceRoot,
user: { id: 'user-1', workspaceRoot },
});
const denied = await requestJson(app, 'GET', '/api/page-data/hidden');
assert.equal(denied.status, 403);
assert.equal(denied.body.error.code, 'action_not_allowed');
});
test('page data routes require login', async () => {
const api = express.Router();
attachPageDataRoutes(api, {
sendData: (res, _req, data) => res.json({ data }),
sendError: (res, _req, status, code, message) => res.status(status).json({ error: { code, message } }),
getPageDataService: () => createPageDataService({ resolveWorkspaceRoot: async () => '/tmp' }),
});
const handler = api.stack.find((layer) => layer.route?.path === '/page-data/:dataset')?.route.stack[0].handle;
const res = createResponseRecorder();
await handler({ params: { dataset: 'demo' } }, res);
assert.equal(res.statusCode, 401);
assert.equal(res.body.error.code, 'unauthorized');
});