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
+176
View File
@@ -0,0 +1,176 @@
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 { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
import { writePageAccessPolicy } from './page-data-policy-store.mjs';
import { publicationInternals } from './mindspace-publications.mjs';
const PAGE_ID = 'page-public-1';
const OWNER_ID = 'user-owner-1';
function createPublicationPool({ accessMode = 'public', password = null } = {}) {
const passwordHash = password ? publicationInternals.hashPassword(password) : null;
return {
async query(sql) {
if (sql.includes('FROM h5_publish_records')) {
return [
[
{
id: 'pub-record-1',
user_id: OWNER_ID,
page_id: PAGE_ID,
access_mode: accessMode,
password_hash: passwordHash,
status: 'online',
expires_at: null,
},
],
];
}
return [[]];
},
};
}
async function setupPublicWorkspace(workspaceRoot, { accessMode = 'public', withRead = false } = {}) {
const service = createUserDataSpaceService({ workspaceRoot });
await service.executeSql(`CREATE TABLE signups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT,
deleted_at TEXT,
deleted_by TEXT,
updated_at TEXT,
updated_by_label TEXT
);`);
await service.upsertDataset({
name: 'signups',
table: 'signups',
actions: ['read', 'insert', 'update', 'soft_delete'],
columns: {
read: ['id', 'name', 'phone', 'status', 'created_at'],
insert: ['name', 'phone', 'status'],
update: ['status'],
soft_delete: ['id'],
},
});
writePageAccessPolicy(workspaceRoot, {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode,
datasets: {
signups: {
read: withRead,
insert: true,
update: withRead,
softDelete: withRead,
columns: {
read: ['id', 'name', 'phone', 'status', 'created_at'],
insert: ['name', 'phone', 'status'],
update: ['status'],
},
},
},
});
return service;
}
function createPublicService(workspaceRoot, poolOptions = {}) {
return createPageDataPublicService({
getPool: () => createPublicationPool(poolOptions),
resolveWorkspaceRootForOwner: () => workspaceRoot,
});
}
test('public page insert works without login for public access mode', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-'));
await setupPublicWorkspace(workspaceRoot);
const service = createPublicService(workspaceRoot, { accessMode: 'public' });
const result = await service.insertRow(PAGE_ID, 'signups', {
ip: '127.0.0.1',
headers: { 'user-agent': 'test' },
}, {
name: '匿名用户',
phone: '13900000000',
});
assert.equal(result.row.name, '匿名用户');
});
test('public page read is denied in public access mode by default', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-read-'));
await setupPublicWorkspace(workspaceRoot);
const service = createPublicService(workspaceRoot, { accessMode: 'public' });
await assert.rejects(
() => service.listRows(PAGE_ID, 'signups', { headers: {} }),
(error) => error.code === 'action_not_allowed',
);
});
test('password page data-auth returns token and enables read', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-password-'));
const ownerService = await setupPublicWorkspace(workspaceRoot, {
accessMode: 'password',
withRead: true,
});
await ownerService.insertRowForDataset(ownerService.getDataset('signups'), {
name: '协作项',
phone: '13800000001',
});
const service = createPublicService(workspaceRoot, {
accessMode: 'password',
password: 'team-2026',
});
const auth = await service.authenticate(PAGE_ID, 'team-2026', { ip: '127.0.0.1' });
assert.ok(auth.token);
const rows = await service.listRows(PAGE_ID, 'signups', {
ip: '127.0.0.1',
headers: { 'x-page-data-token': auth.token },
});
assert.equal(rows.rows.length, 1);
assert.equal(rows.rows[0].name, '协作项');
});
test('password page update and soft delete require token', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-password-mut-'));
const ownerService = await setupPublicWorkspace(workspaceRoot, {
accessMode: 'password',
withRead: true,
});
const inserted = await ownerService.insertRowForDataset(ownerService.getDataset('signups'), {
name: '待更新',
status: 'pending',
});
const service = createPublicService(workspaceRoot, {
accessMode: 'password',
password: 'team-2026',
});
const auth = await service.authenticate(PAGE_ID, 'team-2026', { ip: '127.0.0.1' });
const req = {
ip: '127.0.0.1',
headers: { 'x-page-data-token': auth.token },
body: { updated_by_label: '测试员' },
};
const updated = await service.updateRow(PAGE_ID, 'signups', inserted.row.id, req, {
status: 'done',
});
assert.equal(updated.row.status, 'done');
const deleted = await service.softDeleteRow(PAGE_ID, 'signups', inserted.row.id, req);
assert.equal(deleted.deleted, true);
});
test('isPageDataPublicPath allows public page data routes without auth', () => {
assert.equal(isPageDataPublicPath('/public/pages/page-1/data/signups/rows', 'POST'), true);
assert.equal(isPageDataPublicPath('/public/pages/page-1/data-auth', 'POST'), true);
assert.equal(isPageDataPublicPath('/public/pages/page-1/data/signups', 'GET'), true);
assert.equal(isPageDataPublicPath('/page-data/signups', 'GET'), false);
});