From a07cf078617e61bbe4e24ce6efae83d4bcb88073 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 15 Jul 2026 10:56:22 +0800 Subject: [PATCH] fix(page-data): isolate owner API from public pages --- docs/page-data-api-usage.md | 34 +++++++------ mindspace-page-data-finish-guard.test.mjs | 7 +++ page-access-policy.mjs | 4 +- page-data-acceptance.test.mjs | 8 +-- page-data-html-detect.mjs | 24 ++++++++- page-data-html-detect.test.mjs | 14 +++++- page-data-integration.test.mjs | 22 ++++---- page-data-ops.test.mjs | 8 +-- page-data-public-service.mjs | 18 ++++--- page-data-routes.mjs | 61 ++++++++++++++++------- page-data-routes.test.mjs | 36 +++++++++---- page-data-workspace-bind.mjs | 7 +++ server.mjs | 10 ++-- src/api/client.ts | 24 ++++----- user-data-space-service.mjs | 13 +++++ 15 files changed, 200 insertions(+), 90 deletions(-) diff --git a/docs/page-data-api-usage.md b/docs/page-data-api-usage.md index b778b01..47d8126 100644 --- a/docs/page-data-api-usage.md +++ b/docs/page-data-api-usage.md @@ -20,25 +20,27 @@ 可在发布面板或策略中逐项覆盖。 -## Owner API(需登录) +## Owner API(需登录,仅后台管理界面) + +`/api/page-data/*` 已移除,不可被公开 HTML 调用;旧页面请求会收到 `410 legacy_page_data_api_removed`。 ```text -GET /api/page-data # 列出已注册 dataset -GET /api/page-data/:dataset -POST /api/page-data/:dataset/rows -PATCH /api/page-data/:dataset/rows/:id -DELETE /api/page-data/:dataset/rows/:id # soft delete -POST /api/page-data/:dataset/rows/:id/restore +GET /api/admin/page-data # 列出已注册 dataset +GET /api/admin/page-data/:dataset +POST /api/admin/page-data/:dataset/rows +PATCH /api/admin/page-data/:dataset/rows/:id +DELETE /api/admin/page-data/:dataset/rows/:id # soft delete +POST /api/admin/page-data/:dataset/rows/:id/restore -GET /api/page-data/policies -GET /api/page-data/policies/:pageId -PUT /api/page-data/policies/:pageId -POST /api/page-data/policies/:pageId/apply-publish -GET /api/page-data/policies/:pageId/ops -GET /api/page-data/policies/:pageId/logs -POST /api/page-data/policies/:pageId/tokens/revoke -POST /api/page-data/policies/:pageId/password/reset -POST /api/page-data/policies/:pageId/datasets/:dataset/close +GET /api/admin/page-data/policies +GET /api/admin/page-data/policies/:pageId +PUT /api/admin/page-data/policies/:pageId +POST /api/admin/page-data/policies/:pageId/apply-publish +GET /api/admin/page-data/policies/:pageId/ops +GET /api/admin/page-data/policies/:pageId/logs +POST /api/admin/page-data/policies/:pageId/tokens/revoke +POST /api/admin/page-data/policies/:pageId/password/reset +POST /api/admin/page-data/policies/:pageId/datasets/:dataset/close ``` ## 公开 API(无需平台登录) diff --git a/mindspace-page-data-finish-guard.test.mjs b/mindspace-page-data-finish-guard.test.mjs index d99f886..fdd81a1 100644 --- a/mindspace-page-data-finish-guard.test.mjs +++ b/mindspace-page-data-finish-guard.test.mjs @@ -96,6 +96,13 @@ test('inferPageDataBindAccessMode chooses password for admin html', () => { 'password', ); assert.equal(inferPageDataBindAccessMode('public/diet-survey.html', SURVEY_HTML), 'public'); + assert.equal( + inferPageDataBindAccessMode( + 'public/private-tracker.html', + '', + ), + 'password', + ); }); test('evaluatePageDataFinishGuard detects unbound page data html', () => { diff --git a/page-access-policy.mjs b/page-access-policy.mjs index ffd5afc..8c43860 100644 --- a/page-access-policy.mjs +++ b/page-access-policy.mjs @@ -24,7 +24,9 @@ export function normalizePolicyDataset(name, raw) { insert: Boolean(raw.insert), update: Boolean(raw.update), softDelete: Boolean(raw.softDelete ?? raw.soft_delete), - hardDelete: Boolean(raw.hardDelete ?? raw.hard_delete), + // `delete` is the page-facing spelling for an intentional permanent PG + // delete; retain hardDelete for existing policies. + hardDelete: Boolean(raw.hardDelete ?? raw.hard_delete ?? raw.delete), columns: normalizedColumns, limits: raw.limits && typeof raw.limits === 'object' diff --git a/page-data-acceptance.test.mjs b/page-data-acceptance.test.mjs index 61872ee..4d8699f 100644 --- a/page-data-acceptance.test.mjs +++ b/page-data-acceptance.test.mjs @@ -121,19 +121,19 @@ test('acceptance: agent dataset registry + owner read/insert via Page Data API', await setupWorkspace(workspaceRoot); const app = buildApp(workspaceRoot); - const datasets = await request(app, 'GET', '/api/page-data', { + const datasets = await request(app, 'GET', '/api/admin/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', { + const inserted = await request(app, 'POST', '/api/admin/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', { + const listed = await request(app, 'GET', '/api/admin/page-data/leads?limit=10', { headers: { 'x-test-user': '1' }, }); assert.equal(listed.status, 200); @@ -237,7 +237,7 @@ test('acceptance: apply-publish creates policy for published page', async () => await setupWorkspace(workspaceRoot); const app = buildApp(workspaceRoot); - const applied = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/apply-publish`, { + const applied = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/apply-publish`, { headers: { 'x-test-user': '1' }, body: { datasetName: 'leads', diff --git a/page-data-html-detect.mjs b/page-data-html-detect.mjs index 4662249..623feab 100644 --- a/page-data-html-detect.mjs +++ b/page-data-html-detect.mjs @@ -41,12 +41,21 @@ export function detectPageDataDatasetUsageFromHtml(html) { for (const match of text.matchAll(/\.listRows\(\s*['"]([^'"]+)['"]/g)) { remember(match[1], { read: true }); } + for (const match of text.matchAll(/\.deleteRow\(\s*['"]([^'"]+)['"]/g)) { + // The browser client's deleteRow endpoint intentionally falls back to a + // soft delete unless a policy explicitly grants hard_delete. A page using + // this API must therefore only require the safe, default capability. + remember(match[1], { softDelete: true }); + } for (const match of text.matchAll(/\.insertRow\(\s*([A-Za-z_$][\w$]*)/g)) { remember(constants.get(match[1]) ?? match[1], { insert: true }); } for (const match of text.matchAll(/\.listRows\(\s*([A-Za-z_$][\w$]*)/g)) { remember(constants.get(match[1]) ?? match[1], { read: true }); } + for (const match of text.matchAll(/\.deleteRow\(\s*([A-Za-z_$][\w$]*)/g)) { + remember(constants.get(match[1]) ?? match[1], { softDelete: true }); + } return datasets; } @@ -55,7 +64,16 @@ export function inferPageDataBindAccessMode(relativePath, html) { const usage = detectPageDataDatasetUsageFromHtml(html); const hasRead = [...usage.values()].some((item) => item.read); const hasInsert = [...usage.values()].some((item) => item.insert); - if (/-admin\.html$/i.test(String(relativePath ?? '')) || (hasRead && !hasInsert)) { + // A page that exchanges a password for a Page Data token is intentionally + // protected even when it both reads and writes its dataset (for example, a + // personal tracker). Do not silently re-bind it as public merely because it + // is not named "-admin.html". + const usesServerAuthentication = /\.\s*authenticate\s*\(/.test(String(html ?? '')); + if ( + /-admin\.html$/i.test(String(relativePath ?? '')) || + (hasRead && !hasInsert) || + usesServerAuthentication + ) { return 'password'; } return 'public'; @@ -122,6 +140,10 @@ export function buildPageDataPolicyDatasetsFromRegistry({ html, registryDatasets entry.columns.read = registered.columns?.read ?? []; } } + if (perms.softDelete) { + entry.softDelete = true; + entry.columns.soft_delete = registered.columns?.soft_delete ?? ['id']; + } datasets[name] = entry; } diff --git a/page-data-html-detect.test.mjs b/page-data-html-detect.test.mjs index 56c0c53..1ddcb1a 100644 --- a/page-data-html-detect.test.mjs +++ b/page-data-html-detect.test.mjs @@ -15,14 +15,24 @@ test('htmlUsesForbiddenLegacyPageDataApi detects /api/page-data passthrough', () assert.equal(detectPageDataDatasetUsageFromHtml(html).size, 0); }); -test('detectPageDataDatasetUsageFromHtml finds insert and read datasets', () => { +test('detectPageDataDatasetUsageFromHtml treats browser deleteRow as a soft delete', () => { const html = ` await c.insertRow('tkmind_exp_survey', { satisfaction: '满意' }); await c.listRows('tkmind_exp_survey', { limit: 10 }); + await c.deleteRow('tkmind_exp_survey', 42); `; const usage = detectPageDataDatasetUsageFromHtml(html); assert.equal(usage.size, 1); - assert.deepEqual(usage.get('tkmind_exp_survey'), { insert: true, read: true }); + assert.deepEqual(usage.get('tkmind_exp_survey'), { insert: true, read: true, softDelete: true }); +}); + +test('buildPageDataPolicyDatasetsFromRegistry grants registered soft delete when HTML uses deleteRow', () => { + const datasets = buildPageDataPolicyDatasetsFromRegistry({ + html: `const DATASET = 'mood_notes'; await c.deleteRow(DATASET, 1);`, + registryDatasets: [{ name: 'mood_notes', columns: { soft_delete: ['id'] } }], + }); + assert.equal(datasets.mood_notes.softDelete, true); + assert.deepEqual(datasets.mood_notes.columns.soft_delete, ['id']); }); test('detectPageDataDatasetUsageFromHtml resolves dataset constants', () => { diff --git a/page-data-integration.test.mjs b/page-data-integration.test.mjs index 2e554ca..4b113b0 100644 --- a/page-data-integration.test.mjs +++ b/page-data-integration.test.mjs @@ -133,14 +133,14 @@ test('integration: owner private API and public insert coexist without breaking const service = await setupWorkspace(workspaceRoot); const app = buildApp(workspaceRoot); - const ownerInsert = await request(app, 'POST', '/api/page-data/entries/rows', { + const ownerInsert = await request(app, 'POST', '/api/admin/page-data/entries/rows', { headers: { 'x-test-user': '1' }, body: { title: 'owner 写入' }, }); assert.equal(ownerInsert.status, 201); assert.equal(ownerInsert.body.data.row.title, 'owner 写入'); - const ownerList = await request(app, 'GET', '/api/page-data/entries?limit=10', { + const ownerList = await request(app, 'GET', '/api/admin/page-data/entries?limit=10', { headers: { 'x-test-user': '1' }, }); assert.equal(ownerList.status, 200); @@ -157,7 +157,7 @@ test('integration: owner private API and public insert coexist without breaking }, }, }; - const policyWrite = await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, { + const policyWrite = await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, { headers: { 'x-test-user': '1' }, body: policy, }); @@ -174,7 +174,7 @@ test('integration: owner private API and public insert coexist without breaking assert.equal(publicReadDenied.status, 403); assert.equal(publicReadDenied.body.error.code, 'action_not_allowed'); - const ownerListAfterPublic = await request(app, 'GET', '/api/page-data/entries?limit=10', { + const ownerListAfterPublic = await request(app, 'GET', '/api/admin/page-data/entries?limit=10', { headers: { 'x-test-user': '1' }, }); assert.equal(ownerListAfterPublic.status, 200); @@ -188,7 +188,7 @@ test('integration: public insert rejects SQL injection style payload keys', asyn const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-integration-sql-')); await setupWorkspace(workspaceRoot); const app = buildApp(workspaceRoot); - await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, { + await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, { headers: { 'x-test-user': '1' }, body: { pageId: PAGE_ID, @@ -252,7 +252,7 @@ test('integration: password publication flow still works for read after data-aut const app = express(); app.use('/api', api); - await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, { + await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, { headers: { 'x-test-user': '1' }, body: { pageId: PAGE_ID, @@ -284,7 +284,7 @@ test('integration: public insert writes operation logs for owner review', async const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-logs-int-')); await setupPublicWorkspace(workspaceRoot); const app = buildApp(workspaceRoot); - await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, { + await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, { headers: { 'x-test-user': '1' }, body: { pageId: PAGE_ID, @@ -296,7 +296,7 @@ test('integration: public insert writes operation logs for owner review', async await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, { body: { name: '日志测试', phone: '13800000000' }, }); - const logs = await request(app, 'GET', `/api/page-data/policies/${PAGE_ID}/logs`, { + const logs = await request(app, 'GET', `/api/admin/page-data/policies/${PAGE_ID}/logs`, { headers: { 'x-test-user': '1' }, }); assert.equal(logs.status, 200); @@ -313,7 +313,7 @@ test('integration: owner can export dataset and restore soft-deleted rows', asyn phone: '13800000001', }); const app = buildApp(workspaceRoot); - const exported = await request(app, 'GET', '/api/page-data/signups/export?format=json', { + const exported = await request(app, 'GET', '/api/admin/page-data/signups/export?format=json', { headers: { 'x-test-user': '1' }, }); assert.equal(exported.status, 200); @@ -322,7 +322,7 @@ test('integration: owner can export dataset and restore soft-deleted rows', asyn await ownerService.softDeleteRowForDataset(ownerService.getDataset('signups'), inserted.row.id, { deletedBy: 'test', }); - const restored = await request(app, 'POST', `/api/page-data/signups/rows/${inserted.row.id}/restore`, { + const restored = await request(app, 'POST', `/api/admin/page-data/signups/rows/${inserted.row.id}/restore`, { headers: { 'x-test-user': '1' }, }); assert.equal(restored.status, 200); @@ -334,7 +334,7 @@ test('integration: apply-publish route binds dataset after publication', async ( await setupPublicWorkspace(workspaceRoot); const app = buildApp(workspaceRoot); - const applied = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/apply-publish`, { + const applied = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/apply-publish`, { headers: { 'x-test-user': '1' }, body: { datasetName: 'signups', diff --git a/page-data-ops.test.mjs b/page-data-ops.test.mjs index d9d540c..528b547 100644 --- a/page-data-ops.test.mjs +++ b/page-data-ops.test.mjs @@ -142,7 +142,7 @@ test('ops overview returns datasets stats logs and active sessions', async () => headers: { 'x-page-data-token': (await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, { body: { password: OLD_PASSWORD } })).body.data.token }, }); - const overview = await request(app, 'GET', `/api/page-data/policies/${PAGE_ID}/ops`, { + const overview = await request(app, 'GET', `/api/admin/page-data/policies/${PAGE_ID}/ops`, { headers: { 'x-test-user': '1' }, }); assert.equal(overview.status, 200); @@ -171,7 +171,7 @@ test('owner can revoke all tokens and reset password', async () => { let updatedPasswordHash = null; const app = buildApp(workspaceRoot, createPool({ onUpdate: ([hash]) => { updatedPasswordHash = hash; } }), sessionStore); - const revoked = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/tokens/revoke`, { + const revoked = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/tokens/revoke`, { headers: { 'x-test-user': '1' }, body: { revokeAll: true }, }); @@ -180,7 +180,7 @@ test('owner can revoke all tokens and reset password', async () => { assert.equal(sessionStore.verify(auth1.token), null); assert.equal(sessionStore.verify(auth2.token), null); - const reset = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/password/reset`, { + const reset = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/password/reset`, { headers: { 'x-test-user': '1' }, body: { password: NEW_PASSWORD }, }); @@ -210,7 +210,7 @@ test('owner can close dataset and public insert is denied afterwards', async () body: { password: OLD_PASSWORD }, }); - const closed = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/datasets/signups/close`, { + const closed = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/datasets/signups/close`, { headers: { 'x-test-user': '1' }, }); assert.equal(closed.status, 200); diff --git a/page-data-public-service.mjs b/page-data-public-service.mjs index 3ef15c0..bb76625 100644 --- a/page-data-public-service.mjs +++ b/page-data-public-service.mjs @@ -501,18 +501,21 @@ export function createPageDataPublicService(deps = {}) { }); } - async function softDeleteRow(pageId, datasetName, rowId, req) { + async function deleteRow(pageId, datasetName, rowId, req) { return runPublicAction(async () => { const publication = await queryPublication(pageId); const policy = await loadPolicy(publication); + const deleteAction = policyAllowsAction(policy, datasetName, 'hard_delete') + ? 'hard_delete' + : 'soft_delete'; const access = resolveAccessContext({ publication, policy, req, - action: 'soft_delete', + action: deleteAction, datasetName, }); - enforceRateLimit(req, publication, 'soft_delete'); + enforceRateLimit(req, publication, deleteAction); const { ownerService, effective, policyDataset } = await resolveEffectiveDataset( publication.user_id, policy, @@ -531,13 +534,15 @@ export function createPageDataPublicService(deps = {}) { } : null, ); - const result = await ownerService.softDeleteRowForDataset(effective, rowId, { + const result = deleteAction === 'hard_delete' + ? await ownerService.hardDeleteRowForDataset(effective, rowId, { rowScope }) + : await ownerService.softDeleteRowForDataset(effective, rowId, { deletedBy: access.session?.sessionId ?? 'public', rowScope, }); recordPublicLog(resolveWorkspaceRoot(publication.user_id), publication, { datasetName, - action: 'soft_delete', + action: deleteAction, rowId, req, session: access.session, @@ -781,7 +786,8 @@ export function createPageDataPublicService(deps = {}) { getStats, insertRow, updateRow, - softDeleteRow, + deleteRow, + softDeleteRow: deleteRow, getOwnerPolicy, saveOwnerPolicy, saveOwnerPolicyFromPublish, diff --git a/page-data-routes.mjs b/page-data-routes.mjs index fe9fbac..11b94c2 100644 --- a/page-data-routes.mjs +++ b/page-data-routes.mjs @@ -1,6 +1,16 @@ import { stripPageDataMetaFields } from './page-data-captcha.mjs'; import { mapPageDataPublicError } from './page-data-public-service.mjs'; +// Owner/admin APIs intentionally use a separate namespace from APIs callable +// by published HTML. A generated public page must only use +// `/api/public/pages/:pageId/data/...`; keeping `/api/page-data` live made it +// too easy for an LLM-generated page to accidentally target owner APIs. +const OWNER_PAGE_DATA_API_PREFIX = '/admin/page-data'; + +export function isLegacyPageDataApiPath(pathname) { + return /^\/page-data(?:\/|$)/.test(String(pathname ?? '')); +} + function requireUser(req, res, sendError) { if (!req.currentUser?.id) { sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); @@ -31,7 +41,7 @@ function handlePublicError(res, req, sendError, error) { export function attachPageDataRoutes(api, deps) { const { sendData, sendError, getPageDataService, getPageDataPublicService } = deps; - api.get('/page-data', async (req, res) => { + api.get(OWNER_PAGE_DATA_API_PREFIX, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const service = getPageDataService?.(); @@ -45,7 +55,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.get('/page-data/policies', async (req, res) => { + api.get(`${OWNER_PAGE_DATA_API_PREFIX}/policies`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const publicService = getPageDataPublicService?.(); @@ -58,7 +68,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.get('/page-data/policies/:pageId', async (req, res) => { + api.get(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const publicService = getPageDataPublicService?.(); @@ -72,7 +82,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.post('/page-data/policies/:pageId/apply-publish', async (req, res) => { + api.post(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/apply-publish`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const publicService = getPageDataPublicService?.(); @@ -90,7 +100,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.put('/page-data/policies/:pageId', async (req, res) => { + api.put(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const publicService = getPageDataPublicService?.(); @@ -103,7 +113,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.get('/page-data/policies/:pageId/logs', async (req, res) => { + api.get(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/logs`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const service = getPageDataService?.(); @@ -120,7 +130,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.get('/page-data/policies/:pageId/ops', async (req, res) => { + api.get(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/ops`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const publicService = getPageDataPublicService?.(); @@ -133,7 +143,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.post('/page-data/policies/:pageId/tokens/revoke', async (req, res) => { + api.post(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/tokens/revoke`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const publicService = getPageDataPublicService?.(); @@ -149,7 +159,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.post('/page-data/policies/:pageId/password/reset', async (req, res) => { + api.post(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/password/reset`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const publicService = getPageDataPublicService?.(); @@ -164,7 +174,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.post('/page-data/policies/:pageId/datasets/:dataset/close', async (req, res) => { + api.post(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId/datasets/:dataset/close`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const publicService = getPageDataPublicService?.(); @@ -177,7 +187,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.get('/page-data/:dataset/export', async (req, res) => { + api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/export`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const service = getPageDataService?.(); @@ -203,7 +213,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.get('/page-data/:dataset', async (req, res) => { + api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const service = getPageDataService?.(); @@ -228,7 +238,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.get('/page-data/:dataset/schema', async (req, res) => { + api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/schema`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const service = getPageDataService?.(); @@ -242,7 +252,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.get('/page-data/:dataset/stats', async (req, res) => { + api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/stats`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const service = getPageDataService?.(); @@ -256,7 +266,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.post('/page-data/:dataset/rows', async (req, res) => { + api.post(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const service = getPageDataService?.(); @@ -272,7 +282,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.post('/page-data/:dataset/rows/:rowId/restore', async (req, res) => { + api.post(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows/:rowId/restore`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const service = getPageDataService?.(); @@ -286,7 +296,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.patch('/page-data/:dataset/rows/:rowId', async (req, res) => { + api.patch(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows/:rowId`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const service = getPageDataService?.(); @@ -302,7 +312,7 @@ export function attachPageDataRoutes(api, deps) { } }); - api.delete('/page-data/:dataset/rows/:rowId', async (req, res) => { + api.delete(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows/:rowId`, async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; const service = getPageDataService?.(); @@ -408,7 +418,7 @@ export function attachPageDataRoutes(api, deps) { const publicService = getPageDataPublicService?.(); if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); try { - const result = await publicService.softDeleteRow( + const result = await publicService.deleteRow( req.params.pageId, req.params.dataset, req.params.rowId, @@ -419,4 +429,17 @@ export function attachPageDataRoutes(api, deps) { return handlePublicError(res, req, sendError, error); } }); + + // Do not retain a compatibility alias. It would keep the forbidden API + // usable from generated pages and turn a future prompt regression into a + // production data-access bug. Owner UI has moved to /admin/page-data. + api.use('/page-data', (req, res) => + sendError( + res, + req, + 410, + 'legacy_page_data_api_removed', + '旧版 Page Data API 已移除;公开页面请使用 /api/public/pages/:pageId/data,后台请使用 /api/admin/page-data。', + ), + ); } diff --git a/page-data-routes.test.mjs b/page-data-routes.test.mjs index 7316dc3..212f9bf 100644 --- a/page-data-routes.test.mjs +++ b/page-data-routes.test.mjs @@ -4,7 +4,7 @@ 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 { attachPageDataRoutes, isLegacyPageDataApiPath } from './page-data-routes.mjs'; import { createPageDataService } from './page-data-service.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; @@ -82,21 +82,21 @@ test('page data routes allow logged-in owner to read and insert dataset rows', a user: { id: 'user-1', workspaceRoot }, }); - const inserted = await requestJson(app, 'POST', '/api/page-data/feedback/rows', { + const inserted = await requestJson(app, 'POST', '/api/admin/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'); + const listed = await requestJson(app, 'GET', '/api/admin/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'); + const schema = await requestJson(app, 'GET', '/api/admin/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'); + const stats = await requestJson(app, 'GET', '/api/admin/page-data/feedback/stats'); assert.equal(stats.status, 200); assert.equal(stats.body.data.total, 1); }); @@ -130,21 +130,21 @@ test('page data routes allow owner update and soft delete', async () => { user: { id: 'user-1', workspaceRoot }, }); - const inserted = await requestJson(app, 'POST', '/api/page-data/tasks/rows', { title: '待办' }); + const inserted = await requestJson(app, 'POST', '/api/admin/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}`, { + const updated = await requestJson(app, 'PATCH', `/api/admin/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}`); + const deleted = await requestJson(app, 'DELETE', `/api/admin/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'); + const listed = await requestJson(app, 'GET', '/api/admin/page-data/tasks?limit=10'); assert.equal(listed.status, 200); assert.equal(listed.body.data.rows.length, 0); }); @@ -168,11 +168,25 @@ test('page data routes reject unauthorized dataset action', async () => { user: { id: 'user-1', workspaceRoot }, }); - const denied = await requestJson(app, 'GET', '/api/page-data/hidden'); + const denied = await requestJson(app, 'GET', '/api/admin/page-data/hidden'); assert.equal(denied.status, 403); assert.equal(denied.body.error.code, 'action_not_allowed'); }); +test('legacy page data API is permanently unavailable', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-legacy-')); + const app = createApiApp({ workspaceRoot, user: { id: 'user-1', workspaceRoot } }); + + const blocked = await requestJson(app, 'POST', '/api/page-data/notes/rows', { content: '旧页面' }); + assert.equal(blocked.status, 410); + assert.equal(blocked.body.error.code, 'legacy_page_data_api_removed'); +}); + +test('legacy API path detector does not include the admin namespace', () => { + assert.equal(isLegacyPageDataApiPath('/page-data/notes/rows'), true); + assert.equal(isLegacyPageDataApiPath('/admin/page-data/notes/rows'), false); +}); + test('page data routes require login', async () => { const api = express.Router(); attachPageDataRoutes(api, { @@ -180,7 +194,7 @@ test('page data routes require login', async () => { 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 handler = api.stack.find((layer) => layer.route?.path === '/admin/page-data/:dataset')?.route.stack[0].handle; const res = createResponseRecorder(); await handler({ params: { dataset: 'demo' } }, res); assert.equal(res.statusCode, 401); diff --git a/page-data-workspace-bind.mjs b/page-data-workspace-bind.mjs index daaff27..d8b4bd1 100644 --- a/page-data-workspace-bind.mjs +++ b/page-data-workspace-bind.mjs @@ -152,6 +152,13 @@ function assertRegisteredDatasetTables(userDataSpace, registryDatasets, usage) { action: 'read', }); } + if (requiredUsage.softDelete && !dataset.actions.includes('soft_delete')) { + throw Object.assign(new Error(`dataset「${dataset.name}」未开放软删除`), { + code: 'dataset_action_not_registered', + datasetName: dataset.name, + action: 'soft_delete', + }); + } const actualColumns = userDataSpace.listTableColumns(dataset.table); if (!actualColumns.length) { throw Object.assign(new Error(`dataset 对应表不存在:${dataset.table}`), { diff --git a/server.mjs b/server.mjs index 7bdf230..8a0d12d 100644 --- a/server.mjs +++ b/server.mjs @@ -203,7 +203,7 @@ import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-conf import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs'; import { createExperienceService } from './experience-service.mjs'; import { attachAsrRoutes } from './asr-proxy.mjs'; -import { attachPageDataRoutes } from './page-data-routes.mjs'; +import { attachPageDataRoutes, isLegacyPageDataApiPath } from './page-data-routes.mjs'; import { createPageDataService } from './page-data-service.mjs'; import { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs'; import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs'; @@ -2035,10 +2035,14 @@ api.use(async (req, res, next) => { const plazaPublic = isPlazaPublicRead(req.path, req.method); const pageDataPublic = isPageDataPublicPath(req.path, req.method); + // Let the retired namespace reach its explicit 410 handler below. Without + // this exception the outer auth middleware turns an old public HTML request + // into a misleading 401/403 before the legacy-endpoint block can run. + const legacyPageDataApi = isLegacyPageDataApiPath(req.path); if (userAuth && tkmindProxy) { if (req.userSessionError) { - if (plazaPublic || pageDataPublic) return next(); + if (plazaPublic || pageDataPublic || legacyPageDataApi) return next(); return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' }); } try { @@ -2046,7 +2050,7 @@ api.use(async (req, res, next) => { const me = await userAuth.getMe(req.userToken); if (me) req.currentUser = me; } - if (plazaPublic || pageDataPublic) return next(); + if (plazaPublic || pageDataPublic || legacyPageDataApi) return next(); if (!req.userSession) { return res.status(401).json({ message: '未授权,请重新登录' }); } diff --git a/src/api/client.ts b/src/api/client.ts index 6f4528c..c45f9b0 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1483,24 +1483,24 @@ export async function listOwnerPageDataPolicies(): Promise< updatedAt: number; }>; }; - }>('/page-data/policies'); + }>('/admin/page-data/policies'); return result.data.policies; } export function buildPageDataExportUrl(dataset: string, format: 'json' | 'csv' = 'json') { const params = new URLSearchParams({ format }); - return `/api/page-data/${encodeURIComponent(dataset)}/export?${params.toString()}`; + return `/api/admin/page-data/${encodeURIComponent(dataset)}/export?${params.toString()}`; } export async function listPageDataDatasets(): Promise { - const result = await apiFetch<{ data: { datasets: PageDataDatasetSummary[] } }>('/page-data'); + const result = await apiFetch<{ data: { datasets: PageDataDatasetSummary[] } }>('/admin/page-data'); return result.data.datasets; } export async function getPageDataPolicy(pageId: string): Promise { try { const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>( - `/page-data/policies/${encodeURIComponent(pageId)}`, + `/admin/page-data/policies/${encodeURIComponent(pageId)}`, ); return result.data.policy; } catch (error) { @@ -1522,7 +1522,7 @@ export async function applyPageDataPublishPolicy( }, ): Promise { const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>( - `/page-data/policies/${encodeURIComponent(pageId)}/apply-publish`, + `/admin/page-data/policies/${encodeURIComponent(pageId)}/apply-publish`, { method: 'POST', body: JSON.stringify({ @@ -1539,7 +1539,7 @@ export async function savePageDataPolicy( policy: Partial, ): Promise { const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>( - `/page-data/policies/${encodeURIComponent(pageId)}`, + `/admin/page-data/policies/${encodeURIComponent(pageId)}`, { method: 'PUT', body: JSON.stringify(policy), @@ -1550,7 +1550,7 @@ export async function savePageDataPolicy( export async function getPageDataOpsOverview(pageId: string): Promise { const result = await apiFetch<{ data: PageDataOpsOverview }>( - `/page-data/policies/${encodeURIComponent(pageId)}/ops`, + `/admin/page-data/policies/${encodeURIComponent(pageId)}/ops`, ); return result.data; } @@ -1564,7 +1564,7 @@ export async function listPageDataLogs( if (input.offset != null) params.set('offset', String(input.offset)); const query = params.toString(); const result = await apiFetch<{ data: { pageId: string; logs: PageDataLogEntry[]; count: number } }>( - `/page-data/policies/${encodeURIComponent(pageId)}/logs${query ? `?${query}` : ''}`, + `/admin/page-data/policies/${encodeURIComponent(pageId)}/logs${query ? `?${query}` : ''}`, ); return result.data; } @@ -1574,7 +1574,7 @@ export async function revokePageDataTokens( input: { revokeAll?: boolean; token?: string }, ): Promise<{ pageId: string; revokedCount: number; revokeAll: boolean }> { const result = await apiFetch<{ data: { pageId: string; revokedCount: number; revokeAll: boolean } }>( - `/page-data/policies/${encodeURIComponent(pageId)}/tokens/revoke`, + `/admin/page-data/policies/${encodeURIComponent(pageId)}/tokens/revoke`, { method: 'POST', body: JSON.stringify({ @@ -1592,7 +1592,7 @@ export async function resetPageDataPassword( ): Promise<{ pageId: string; passwordReset: boolean; revokedSessions: number }> { const result = await apiFetch<{ data: { pageId: string; passwordReset: boolean; revokedSessions: number }; - }>(`/page-data/policies/${encodeURIComponent(pageId)}/password/reset`, { + }>(`/admin/page-data/policies/${encodeURIComponent(pageId)}/password/reset`, { method: 'POST', body: JSON.stringify({ password }), }); @@ -1604,7 +1604,7 @@ export async function closePageDataDataset( dataset: string, ): Promise<{ pageId: string; dataset: string; closed: boolean }> { const result = await apiFetch<{ data: { pageId: string; dataset: string; closed: boolean } }>( - `/page-data/policies/${encodeURIComponent(pageId)}/datasets/${encodeURIComponent(dataset)}/close`, + `/admin/page-data/policies/${encodeURIComponent(pageId)}/datasets/${encodeURIComponent(dataset)}/close`, { method: 'POST' }, ); return result.data; @@ -1615,7 +1615,7 @@ export async function restorePageDataRow( rowId: number | string, ): Promise<{ restored: boolean; row: Record }> { const result = await apiFetch<{ data: { restored: boolean; row: Record } }>( - `/page-data/${encodeURIComponent(dataset)}/rows/${encodeURIComponent(String(rowId))}/restore`, + `/admin/page-data/${encodeURIComponent(dataset)}/rows/${encodeURIComponent(String(rowId))}/restore`, { method: 'POST' }, ); return result.data; diff --git a/user-data-space-service.mjs b/user-data-space-service.mjs index 7279265..3703c93 100644 --- a/user-data-space-service.mjs +++ b/user-data-space-service.mjs @@ -674,6 +674,18 @@ export function createUserDataSpaceService(options = {}) { return { dataset, id, deleted: true }; } + async function hardDeleteRowForDataset(dataset, rowId, meta = {}) { + assertDatasetAction(dataset, 'hard_delete'); + const table = assertSafeSqlIdentifier(dataset.table, '表名'); + const id = Number(rowId); + if (!Number.isFinite(id) || id <= 0) { + throw Object.assign(new Error('行 id 无效'), { code: 'invalid_row_id' }); + } + const scopeClause = meta.rowScope?.whereClause ? ` AND ${meta.rowScope.whereClause}` : ''; + await executeSql(`DELETE FROM "${table}" WHERE id = ${id}${scopeClause};`); + return { dataset, id, deleted: true, permanentlyDeleted: true }; + } + async function restoreSoftDeletedRowForDataset(dataset, rowId) { const table = assertSafeSqlIdentifier(dataset.table, '表名'); const id = Number(rowId); @@ -795,6 +807,7 @@ export function createUserDataSpaceService(options = {}) { updateRowForDataset, softDeleteDatasetRow, softDeleteRowForDataset, + hardDeleteRowForDataset, restoreSoftDeletedRowForDataset, restoreSoftDeletedRow, exportDatasetRows,