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:
+111
-186
@@ -17,6 +17,9 @@ import mysql from 'mysql2/promise';
|
||||
import { createScheduleService } from './schedule-service.mjs';
|
||||
import { resolveScheduleTimestamp } from './schedule-time.mjs';
|
||||
import { renderLongImage } from './mindspace-long-image.mjs';
|
||||
import { createUserDataSpaceService } from './user-data-space-service.mjs';
|
||||
import { writePageAccessPolicy } from './page-data-policy-store.mjs';
|
||||
import { normalizePageAccessPolicy } from './page-access-policy.mjs';
|
||||
|
||||
const SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim();
|
||||
if (!SANDBOX_ROOT) {
|
||||
@@ -27,7 +30,6 @@ if (!SANDBOX_ROOT) {
|
||||
const SANDBOX = path.resolve(SANDBOX_ROOT);
|
||||
const SANDBOX_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PRIVATE_DATA_DIR = path.join(SANDBOX, '.mindspace');
|
||||
const PRIVATE_DATA_DB = path.join(PRIVATE_DATA_DIR, 'private-data.sqlite');
|
||||
const SQLITE_BIN = process.env.SQLITE_BIN?.trim() || 'sqlite3';
|
||||
const PRIVATE_DATA_MAX_BYTES = Number(process.env.PRIVATE_DATA_MAX_BYTES ?? 20 * 1024 * 1024);
|
||||
const PRIVATE_DATA_QUERY_TIMEOUT_MS = Number(process.env.PRIVATE_DATA_QUERY_TIMEOUT_MS ?? 5000);
|
||||
@@ -242,10 +244,60 @@ const ALL_TOOLS = [
|
||||
required: ['sql'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'private_data_register_dataset',
|
||||
description:
|
||||
'注册或更新页面可访问的 dataset。Agent 建表后应注册 dataset,供 HTML 页面通过 Page Data API 受控读写。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'dataset 名称,例如 registrations' },
|
||||
table: { type: 'string', description: '映射的 SQLite 表名' },
|
||||
description: { type: 'string', description: 'dataset 说明,可选' },
|
||||
actions: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: '允许动作,例如 read、insert、update、soft_delete',
|
||||
},
|
||||
columns: {
|
||||
type: 'object',
|
||||
description: '各动作允许的字段白名单,例如 { read: ["id","name"], insert: ["name"] }',
|
||||
},
|
||||
limits: {
|
||||
type: 'object',
|
||||
description: '限制,例如 { maxRowsPerRead: 100, maxInsertBytes: 8192 }',
|
||||
},
|
||||
},
|
||||
required: ['name', 'table', 'actions', 'columns'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'private_data_set_page_policy',
|
||||
description:
|
||||
'为已发布页面配置 Page Data 访问策略。策略保存在工作区 .mindspace/page-data-policies/ 下,供公开页面通过 Page Data API 受控读写。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageId: { type: 'string', description: '页面 ID(h5_page_records.id)' },
|
||||
ownerUserId: { type: 'string', description: '页面 owner 用户 ID,可选,默认当前会话用户' },
|
||||
accessMode: {
|
||||
type: 'string',
|
||||
description: '访问模式:public、password、login_required',
|
||||
},
|
||||
datasets: {
|
||||
type: 'object',
|
||||
description:
|
||||
'dataset 授权,例如 { registrations: { insert: true, columns: { insert: ["name","phone"] } } }',
|
||||
},
|
||||
},
|
||||
required: ['pageId', 'accessMode', 'datasets'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let quotaPool = null;
|
||||
let scheduleService = null;
|
||||
let userDataSpaceService = null;
|
||||
|
||||
function isQuotaSyncConfigured() {
|
||||
return Boolean(
|
||||
@@ -352,169 +404,19 @@ if (isScheduleConfigured()) {
|
||||
|
||||
const TOOLS = ALLOWED_TOOLS ? ALL_TOOLS.filter((t) => ALLOWED_TOOLS.has(t.name)) : ALL_TOOLS;
|
||||
|
||||
function ensurePrivateDataDb() {
|
||||
fs.mkdirSync(PRIVATE_DATA_DIR, { recursive: true });
|
||||
if (!fs.existsSync(PRIVATE_DATA_DB)) {
|
||||
runSqlite(['-batch', PRIVATE_DATA_DB, 'PRAGMA journal_mode=WAL; PRAGMA user_version = 1;']);
|
||||
function getUserDataSpaceService() {
|
||||
if (!userDataSpaceService) {
|
||||
userDataSpaceService = createUserDataSpaceService({
|
||||
workspaceRoot: SANDBOX,
|
||||
userId: PRIVATE_DATA_USER_ID || null,
|
||||
query: getQuotaPool(),
|
||||
sqliteBin: SQLITE_BIN,
|
||||
maxBytes: PRIVATE_DATA_MAX_BYTES,
|
||||
queryTimeoutMs: PRIVATE_DATA_QUERY_TIMEOUT_MS,
|
||||
maxRows: PRIVATE_DATA_MAX_ROWS,
|
||||
});
|
||||
}
|
||||
return PRIVATE_DATA_DB;
|
||||
}
|
||||
|
||||
function privateDataSize() {
|
||||
let total = 0;
|
||||
for (const file of fs.existsSync(PRIVATE_DATA_DIR) ? fs.readdirSync(PRIVATE_DATA_DIR) : []) {
|
||||
if (file === 'private-data.sqlite' || file.startsWith('private-data.sqlite-')) {
|
||||
total += fs.statSync(path.join(PRIVATE_DATA_DIR, file)).size;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function runSqlite(args) {
|
||||
return execFileSync(SQLITE_BIN, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: PRIVATE_DATA_QUERY_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
function sqliteScalar(sql) {
|
||||
return Number(runSqlite(['-batch', '-noheader', PRIVATE_DATA_DB, sql]).trim() || 0);
|
||||
}
|
||||
|
||||
async function getQuotaState({ forUpdate = false, conn = null } = {}) {
|
||||
const pool = getQuotaPool();
|
||||
if (!pool) return null;
|
||||
const db = conn ?? pool;
|
||||
const [rows] = await db.query(
|
||||
`SELECT id, quota_bytes, used_bytes, reserved_bytes, status
|
||||
FROM h5_user_spaces
|
||||
WHERE user_id = ?
|
||||
LIMIT 1 ${forUpdate ? 'FOR UPDATE' : ''}`,
|
||||
[PRIVATE_DATA_USER_ID],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
const quotaBytes = Number(row.quota_bytes ?? 0);
|
||||
const usedBytes = Number(row.used_bytes ?? 0);
|
||||
const reservedBytes = Number(row.reserved_bytes ?? 0);
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
quotaBytes,
|
||||
usedBytes,
|
||||
reservedBytes,
|
||||
availableBytes: Math.max(0, quotaBytes - usedBytes - reservedBytes),
|
||||
};
|
||||
}
|
||||
|
||||
async function sqliteMaxPagePragmaForQuota(currentBytes) {
|
||||
const quota = await getQuotaState();
|
||||
if (!quota) return '';
|
||||
if (quota.status !== 'active') throw new Error('用户空间不可写');
|
||||
if (quota.availableBytes <= 0 && currentBytes >= PRIVATE_DATA_MAX_BYTES) {
|
||||
throw new Error('用户私有数据空间配额不足');
|
||||
}
|
||||
const allowedSize = Math.min(PRIVATE_DATA_MAX_BYTES, currentBytes + quota.availableBytes);
|
||||
const pageSize = sqliteScalar('PRAGMA page_size;') || 4096;
|
||||
const currentPages = sqliteScalar('PRAGMA page_count;') || 1;
|
||||
const maxPages = Math.max(currentPages, Math.max(1, Math.floor(allowedSize / pageSize)));
|
||||
return `PRAGMA max_page_count=${maxPages};`;
|
||||
}
|
||||
|
||||
async function syncPrivateDataQuota(deltaBytes) {
|
||||
const pool = getQuotaPool();
|
||||
if (!pool || !deltaBytes) return null;
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const quota = await getQuotaState({ forUpdate: true, conn });
|
||||
if (!quota) {
|
||||
await conn.rollback();
|
||||
return null;
|
||||
}
|
||||
if (quota.status !== 'active') throw new Error('用户空间不可写');
|
||||
if (deltaBytes > quota.availableBytes) {
|
||||
throw new Error(`用户私有数据空间配额不足:需要 ${deltaBytes} 字节,可用 ${quota.availableBytes} 字节`);
|
||||
}
|
||||
await conn.query(
|
||||
`UPDATE h5_user_spaces
|
||||
SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ?
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[deltaBytes, Date.now(), quota.id, PRIVATE_DATA_USER_ID],
|
||||
);
|
||||
await conn.commit();
|
||||
return { deltaBytes };
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
function stripSqlComments(sql) {
|
||||
return String(sql ?? '')
|
||||
.replace(/--.*$/gm, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function rejectDangerousSql(sql, { readonly = false } = {}) {
|
||||
const cleaned = stripSqlComments(sql);
|
||||
if (!cleaned) throw new Error('SQL 不能为空');
|
||||
if (cleaned.length > 20000) throw new Error('SQL 过长');
|
||||
if (/^\s*\./m.test(cleaned)) {
|
||||
throw new Error('SQL 不允许使用 sqlite3 dot command');
|
||||
}
|
||||
if (/\b(ATTACH|DETACH|LOAD_EXTENSION|VACUUM\s+INTO)\b/i.test(cleaned)) {
|
||||
throw new Error('SQL 包含用户私有数据空间不允许的操作');
|
||||
}
|
||||
if (/\bPRAGMA\s+writable_schema\b/i.test(cleaned)) {
|
||||
throw new Error('SQL 包含不允许的 PRAGMA');
|
||||
}
|
||||
if (readonly && !/^\s*(SELECT|WITH)\b/i.test(cleaned)) {
|
||||
throw new Error('private_data_query 只允许 SELECT/WITH');
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
async function queryPrivateData(sql) {
|
||||
const db = ensurePrivateDataDb();
|
||||
const cleaned = rejectDangerousSql(sql, { readonly: true });
|
||||
const limited = `SELECT * FROM (${cleaned.replace(/;\s*$/, '')}) LIMIT ${PRIVATE_DATA_MAX_ROWS}`;
|
||||
const output = runSqlite(['-json', db, limited]);
|
||||
return output.trim() || '[]';
|
||||
}
|
||||
|
||||
async function executePrivateData(sql) {
|
||||
const db = ensurePrivateDataDb();
|
||||
const before = privateDataSize();
|
||||
if (before > PRIVATE_DATA_MAX_BYTES) throw new Error('用户私有数据空间已超过大小限制');
|
||||
const cleaned = rejectDangerousSql(sql);
|
||||
const quotaPragma = await sqliteMaxPagePragmaForQuota(before);
|
||||
runSqlite(['-batch', db, `${quotaPragma}\n${cleaned}`]);
|
||||
const after = privateDataSize();
|
||||
if (after > PRIVATE_DATA_MAX_BYTES) {
|
||||
throw new Error(`用户私有数据空间超过大小限制:${after}/${PRIVATE_DATA_MAX_BYTES} 字节`);
|
||||
}
|
||||
const delta = after - before;
|
||||
const quotaSync = await syncPrivateDataQuota(delta);
|
||||
return `已执行。当前数据空间大小 ${after} 字节${quotaSync ? `,已同步空间占用 ${delta} 字节` : ''}`;
|
||||
}
|
||||
|
||||
async function privateDataSchema() {
|
||||
const db = ensurePrivateDataDb();
|
||||
const output = runSqlite([
|
||||
'-json',
|
||||
db,
|
||||
`SELECT m.name AS table_name, p.cid, p.name AS column_name, p.type, p."notnull" AS not_null, p.pk
|
||||
FROM sqlite_master m
|
||||
JOIN pragma_table_info(m.name) p
|
||||
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
|
||||
ORDER BY m.name, p.cid`,
|
||||
]);
|
||||
return output.trim() || '[]';
|
||||
return userDataSpaceService;
|
||||
}
|
||||
|
||||
async function callTool(name, args) {
|
||||
@@ -595,32 +497,18 @@ async function callTool(name, args) {
|
||||
];
|
||||
}
|
||||
case 'private_data_info': {
|
||||
ensurePrivateDataDb();
|
||||
const size = privateDataSize();
|
||||
const quota = await getQuotaState().catch(() => null);
|
||||
const info = await getUserDataSpaceService().getInfo();
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
name: '用户私有数据空间',
|
||||
database: '.mindspace/private-data.sqlite',
|
||||
maxBytes: PRIVATE_DATA_MAX_BYTES,
|
||||
sizeBytes: size,
|
||||
quotaSyncEnabled: Boolean(getQuotaPool()),
|
||||
quota: quota
|
||||
? {
|
||||
status: quota.status,
|
||||
quotaBytes: quota.quotaBytes,
|
||||
usedBytes: quota.usedBytes,
|
||||
reservedBytes: quota.reservedBytes,
|
||||
availableBytes: quota.availableBytes,
|
||||
}
|
||||
: null,
|
||||
...info,
|
||||
rules: [
|
||||
'每个用户只能使用这个唯一 SQLite 数据库',
|
||||
'适合问卷、表单、清单、调研数据和分析中间表',
|
||||
'不要存账号、计费、权限、审计、公开平台数据或跨用户数据',
|
||||
'HTML 页面通过 Page Data API 访问 dataset,不要直接暴露 SQL',
|
||||
],
|
||||
},
|
||||
null,
|
||||
@@ -629,12 +517,49 @@ async function callTool(name, args) {
|
||||
},
|
||||
];
|
||||
}
|
||||
case 'private_data_schema':
|
||||
return [{ type: 'text', text: await privateDataSchema() }];
|
||||
case 'private_data_query':
|
||||
return [{ type: 'text', text: await queryPrivateData(args.sql) }];
|
||||
case 'private_data_execute':
|
||||
return [{ type: 'text', text: await executePrivateData(args.sql) }];
|
||||
case 'private_data_schema': {
|
||||
const schema = await getUserDataSpaceService().getSchema();
|
||||
return [{ type: 'text', text: JSON.stringify(schema, null, 2) }];
|
||||
}
|
||||
case 'private_data_query': {
|
||||
const rows = await getUserDataSpaceService().querySql(args.sql);
|
||||
return [{ type: 'text', text: JSON.stringify(rows, null, 2) }];
|
||||
}
|
||||
case 'private_data_execute': {
|
||||
const result = await getUserDataSpaceService().executeSql(args.sql);
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: `已执行。当前数据空间大小 ${result.sizeBytes} 字节${result.quotaSynced ? `,已同步空间占用 ${result.deltaBytes} 字节` : ''}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
case 'private_data_register_dataset': {
|
||||
const dataset = await getUserDataSpaceService().upsertDataset({
|
||||
name: args.name,
|
||||
table: args.table,
|
||||
description: args.description,
|
||||
actions: args.actions,
|
||||
columns: args.columns,
|
||||
limits: args.limits,
|
||||
});
|
||||
return [{ type: 'text', text: JSON.stringify(dataset, null, 2) }];
|
||||
}
|
||||
case 'private_data_set_page_policy': {
|
||||
const ownerUserId = String(args.ownerUserId ?? PRIVATE_DATA_USER_ID ?? '').trim();
|
||||
if (!ownerUserId) throw new Error('缺少 ownerUserId,无法保存页面数据策略');
|
||||
const policy = normalizePageAccessPolicy(
|
||||
{
|
||||
pageId: args.pageId,
|
||||
ownerUserId,
|
||||
accessMode: args.accessMode,
|
||||
datasets: args.datasets,
|
||||
},
|
||||
{ fallbackPageId: args.pageId, fallbackOwnerUserId: ownerUserId },
|
||||
);
|
||||
const saved = writePageAccessPolicy(SANDBOX, policy);
|
||||
return [{ type: 'text', text: JSON.stringify(saved, null, 2) }];
|
||||
}
|
||||
case 'schedule_create_item': {
|
||||
const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai';
|
||||
const startAt = resolveScheduleTimestamp({
|
||||
|
||||
Reference in New Issue
Block a user