diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index e1a90b3e8f..a0d14d138d 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -2893,6 +2893,7 @@ function exitWithStructuredError({ code, message, data }) { // // Daemon error envelopes come in two shapes in practice: // { error: { code, message, ... } } — newer routes using sendApiError +// { error: '', message, ... } — workspace authority routes // { error: '' } — older flat-string routes // (e.g. POST /api/templates at // routes/project/index.ts) @@ -2911,7 +2912,9 @@ async function structuredHttpFailure(resp, fallbackCode = 'daemon-not-running') } const errorObj = typeof parsed?.error === 'string' - ? { message: parsed.error } + ? typeof parsed.message === 'string' + ? { ...parsed, code: parsed.error } + : { message: parsed.error } : parsed?.error; const errCode = normalizeRecoverableErrorCode(errorObj?.code, errorObj?.message); if (errCode) { diff --git a/apps/daemon/src/routes/project/index.ts b/apps/daemon/src/routes/project/index.ts index c85a5e4356..7502ac295f 100644 --- a/apps/daemon/src/routes/project/index.ts +++ b/apps/daemon/src/routes/project/index.ts @@ -6712,7 +6712,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile 'writeFiles', )) return; const target = await resolveProjectFilePath(PROJECTS_DIR, project.id, folderPath, project.metadata); - if (!target.name || !(await ctx.node.fs.stat(target.filePath)).isDirectory()) { + if (!target.name || !(await ctx.node.fs.promises.stat(target.filePath)).isDirectory()) { return sendApiError(res, 400, 'BAD_REQUEST', 'target must be a non-root folder'); } await ctx.stopPublicFilesBeforeDelete?.(project.id, { folderPath: target.name }); @@ -7217,7 +7217,10 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile res, status, status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', - String(err), + // Never expose filesystem error messages: they contain daemon-owned paths. + status === 404 ? 'file not found' + : err instanceof ProjectPublicFileStopPendingError ? 'PUBLIC_FILE_STOP_PENDING' + : 'file could not be deleted', ); } })); @@ -7930,7 +7933,10 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile res, status, status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', - String(err), + // Never expose filesystem error messages: they contain daemon-owned paths. + status === 404 ? 'file not found' + : err instanceof ProjectPublicFileStopPendingError ? 'PUBLIC_FILE_STOP_PENDING' + : 'file could not be deleted', ); } })); diff --git a/apps/daemon/tests/project-cli.test.ts b/apps/daemon/tests/project-cli.test.ts index f3a4d6d1fe..21c4d1f945 100644 --- a/apps/daemon/tests/project-cli.test.ts +++ b/apps/daemon/tests/project-cli.test.ts @@ -37,7 +37,7 @@ afterEach(async () => { tempRoot = ''; }); -async function startProjectStubServer(deleteResponse: unknown = { ok: true }, shareResponse?: unknown): Promise { +async function startProjectStubServer(deleteResponse: unknown = { ok: true }, shareResponse?: unknown, shareStatus = 200): Promise { const requests: CapturedRequest[] = []; const server = http.createServer((req, res) => { let raw = ''; @@ -114,7 +114,7 @@ async function startProjectStubServer(deleteResponse: unknown = { ok: true }, sh } if (['POST', 'GET'].includes(captured.method) && captured.url === '/api/projects/project-1/files/nested%2Findex.html/publish-public') { - if (shareResponse !== undefined) { res.end(JSON.stringify(shareResponse)); return; } + if (shareResponse !== undefined) { res.statusCode = shareStatus; res.end(JSON.stringify(shareResponse)); return; } const publication = { url: 'https://example.invalid/returned-link', slug: 'returned-slug', fileName: 'nested/index.html' }; res.end(JSON.stringify(captured.method === 'GET' ? { publication } : publication)); return; @@ -247,6 +247,17 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; } describe('od project CLI', () => { + it.each([false, true])('resume reports missing workspace rather than an unavailable daemon; nested=%s', async nested => { + const code = 'WORKSPACE_CONTEXT_REQUIRED'; + const message = 'an explicit workspace context is required'; + stub = await startProjectStubServer(undefined, nested ? { error: { code, message } } : { error: code, message }, 400); + const result = await runCli(['project', 'share', 'resume', 'project-1', '--path', 'nested/index.html', '--daemon-url', stub.baseUrl, '--json']); + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(JSON.parse(result.stderr)).toEqual({ error: { code, message, data: {} } }); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]!.headers['x-od-workspace-id']).toBeUndefined(); + }); it.each([false, true])('files delete preserves the HTTP response or human output, json=%s', async json => { const body = { ok: true }; stub = await startProjectStubServer(body); diff --git a/apps/daemon/tests/routes/file-delete-stops-publication.test.ts b/apps/daemon/tests/routes/file-delete-stops-publication.test.ts index ff60358941..c69946438d 100644 --- a/apps/daemon/tests/routes/file-delete-stops-publication.test.ts +++ b/apps/daemon/tests/routes/file-delete-stops-publication.test.ts @@ -1,4 +1,8 @@ import http from 'node:http'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import nodeFs from 'node:fs'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -18,7 +22,10 @@ it.each(['raw', 'files'] as const)('DELETE %s stops only the selected publicatio it('failed stop preserves file bytes and durably records the exact residual', async () => { await exercise('raw', true); }); -async function exercise(route: 'raw' | 'files', fail: boolean) { +it.each(['raw', 'files'] as const)('DELETE %s conceals internal paths in HTTP and CLI failures', async route => { + await exercise(route, false, true); +}); +async function exercise(route: 'raw' | 'files', fail: boolean, missing = false) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'od-file-stop-')); const projects = path.join(root, 'projects'); const db = openDatabase(root); @@ -40,15 +47,36 @@ async function exercise(route: 'raw' | 'files', fail: boolean) { } })); const app = express(); app.use(express.json()); registerProjectFileRoutes(app, { - db, paths: { PROJECTS_DIR: projects }, http: { sendApiError: (res: express.Response, status: number, code: string) => res.status(status).json({ error: code }) }, + db, paths: { PROJECTS_DIR: projects }, http: { sendApiError: (res: express.Response, status: number, code: string, message: string) => res.status(status).json({ error: { code, message } }) }, projectStore: { getProject, getWorkspaceProject, getWorkspaceProjectByProjectId }, projectFiles, - node: { fs }, uploads: {}, documents: {}, artifacts: {}, projectPreviewScopes: {}, + node: { fs: nodeFs }, uploads: {}, documents: {}, artifacts: {}, projectPreviewScopes: {}, publicFileMutations: createPublicFileMutations(), stopPublicFilesBeforeDelete: (id: string, filePath?: string) => stop({ ...scope, projectId: id }, filePath), verifyWorkspaceRequestAuthority: async () => ({ ok: true, context: workspaceContextFromDirectoryItem({ workspaceId: scope.resourceTeamId, workspaceMemberId: scope.ownerMemberId, workspaceName: 'W', workspaceType: 'personal', role: 'owner', memberStatus: 'active', lifecycleState: 'active' }) }), } as unknown as Parameters[1]); server = app.listen(0); await new Promise(resolve => server!.once('listening', resolve)); const address = server.address(); if (!address || typeof address === 'string') throw new Error('missing port'); + if (missing) { + const base = `http://127.0.0.1:${address.port}`; + const response = await fetch(`${base}/api/projects/${projectId}/${route}/missing.html`, { method: 'DELETE', headers: { 'x-od-workspace-id': scope.resourceTeamId, 'x-od-workspace-member-id': scope.ownerMemberId } }); + expect(response.status).toBe(404); + const text = await response.text(); + expect.soft(text).not.toContain(root); + expect.soft(JSON.parse(text)).toEqual({ error: { code: 'FILE_NOT_FOUND', message: 'file not found' } }); + if (route === 'files') { + const env = { ...process.env }; delete env.NODE_OPTIONS; + const result = await promisify(execFile)(process.execPath, ['--import', 'tsx', fileURLToPath(new URL('../../src/cli.ts', import.meta.url)), 'files', 'delete', projectId, 'missing.html', '--workspace', scope.resourceTeamId, '--workspace-member', scope.ownerMemberId, '--daemon-url', base, '--json'], { env, timeout: 15000 }).then( + value => ({ ...value, code: 0 }), + (error: { stdout: string; stderr: string; code: number }) => error, + ); + expect(result.code).not.toBe(0); + expect(result.stdout).toBe(''); + expect.soft(result.stderr).not.toContain(root); + expect.soft(JSON.parse(result.stderr)).toMatchObject({ error: { code: 'FILE_NOT_FOUND', message: 'file not found' } }); + } + expect(remote.size).toBe(2); + return; + } const response = await fetch(`http://127.0.0.1:${address.port}/api/projects/${projectId}/${route}/index.html`, { method: 'DELETE', headers: { 'x-od-workspace-id': scope.resourceTeamId, 'x-od-workspace-member-id': scope.ownerMemberId } }); expect(response.status).toBe(fail ? 400 : 200); expect(remote.has('index.html')).toBe(fail); diff --git a/apps/daemon/tests/routes/folder-delete-stops-publication.test.ts b/apps/daemon/tests/routes/folder-delete-stops-publication.test.ts index c1f9bf6268..067f0ddd1a 100644 --- a/apps/daemon/tests/routes/folder-delete-stops-publication.test.ts +++ b/apps/daemon/tests/routes/folder-delete-stops-publication.test.ts @@ -1,5 +1,6 @@ import http from 'node:http'; import fs from 'node:fs/promises'; +import nodeFs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import express from 'express'; @@ -26,12 +27,12 @@ it.each([false, true])('folder deletion revokes descendants only; failure=%s pre const app=express();app.use(express.json()); registerProjectFileRoutes(app,{ db,paths:{PROJECTS_DIR:projects},http:{sendApiError:(res:express.Response,status:number,code:string)=>res.status(status).json({error:code})}, - projectStore:{getProject,getWorkspaceProject,getWorkspaceProjectByProjectId},projectFiles,node:{fs},uploads:{},documents:{},artifacts:{},projectPreviewScopes:{}, + projectStore:{getProject,getWorkspaceProject,getWorkspaceProjectByProjectId},projectFiles,node:{fs:nodeFs},uploads:{},documents:{},artifacts:{},projectPreviewScopes:{}, publicFileMutations:createPublicFileMutations(),stopPublicFilesBeforeDelete:(id:string,target:Parameters[1])=>stop({...scope,projectId:id},target), verifyWorkspaceRequestAuthority:async()=>({ok:true,context:workspaceContextFromDirectoryItem({workspaceId:scope.resourceTeamId,workspaceMemberId:scope.ownerMemberId,workspaceName:'W',workspaceType:'personal',role:'owner',memberStatus:'active',lifecycleState:'active'})}), } as unknown as Parameters[1]); server=app.listen(0);await new Promise(resolve=>server!.once('listening',resolve));const address=server.address();if(!address||typeof address==='string')throw new Error('missing port'); - const response=await fetch(`http://127.0.0.1:${address.port}/api/projects/project/folders`,{method:'DELETE',headers:{'content-type':'application/json','x-od-workspace-id':scope.resourceTeamId,'x-od-workspace-member-id':scope.ownerMemberId},body:JSON.stringify({path:'folder/'})}); + const response=await fetch(`http://127.0.0.1:${address.port}/api/projects/project/folders`,{method:'DELETE',headers:{'content-type':'application/json','x-od-workspace-id':scope.resourceTeamId,'x-od-workspace-member-id':scope.ownerMemberId},body:JSON.stringify({path:'folder'})}); expect(response.status).toBe(fail?400:200); for(const filePath of files){const kept=fail||!filePath.startsWith('folder/');expect(remote.has(filePath)).toBe(kept);expect(store.get({...scope,filePath})!==null).toBe(kept);if(kept)expect(await fs.readFile(path.join(projects,scope.projectId,filePath),'utf8')).toBe(filePath);else await expect(fs.stat(path.join(projects,scope.projectId,filePath))).rejects.toMatchObject({code:'ENOENT'});} expect(store.listStops().map(task=>task.filePath).sort()).toEqual(fail?files.slice(0,2).sort():[]);