Merge branch 'feat/share-lane1-publish' into feat/share-comment-p0

This commit is contained in:
a1chzt
2026-09-24 00:19:23 +08:00
5 changed files with 60 additions and 11 deletions
+4 -1
View File
@@ -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: '<code>', message, ... } — workspace authority routes
// { error: '<message>' } — 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) {
+9 -3
View File
@@ -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',
);
}
}));
+13 -2
View File
@@ -37,7 +37,7 @@ afterEach(async () => {
tempRoot = '';
});
async function startProjectStubServer(deleteResponse: unknown = { ok: true }, shareResponse?: unknown): Promise<StubServer> {
async function startProjectStubServer(deleteResponse: unknown = { ok: true }, shareResponse?: unknown, shareStatus = 200): Promise<StubServer> {
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);
@@ -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<typeof registerProjectFileRoutes>[1]);
server = app.listen(0); await new Promise<void>(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);
@@ -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<typeof stop>[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<typeof registerProjectFileRoutes>[1]);
server=app.listen(0);await new Promise<void>(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():[]);