diff --git a/backend/__tests__/unit/routes/agentsRuntime.fileContentType.test.js b/backend/__tests__/unit/routes/agentsRuntime.fileContentType.test.js index 923011be6..9bea274da 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.fileContentType.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.fileContentType.test.js @@ -192,3 +192,29 @@ describe('pod file read — content type classification', () => { }, ); }); + +// Both byte sources return the bytes in this JSON body, so both must send the +// same Cache-Control header. +describe('pod file read — Cache-Control', () => { + beforeEach(() => jest.clearAllMocks()); + + test('object-store bytes are sent private, no-store', async () => { + serveFile({ contentType: 'text/plain', bytes: Buffer.from('STORE_SENTINEL_DELTA\n') }); + const res = await read(); + + expect(res.status).toBe(200); + expect(res.body.content).toBe('STORE_SENTINEL_DELTA\n'); + expect(res.headers['cache-control']).toBe('private, no-store'); + }); + + test('legacy inline bytes are sent private, no-store', async () => { + serveFile({ contentType: 'text/plain', bytes: Buffer.from('unused') }); + getObjectStore().get.mockResolvedValue(null); + File.findByFileName.mockResolvedValue({ data: Buffer.from('LEGACY_SENTINEL_EPSILON\n') }); + const res = await read(); + + expect(res.status).toBe(200); + expect(res.body.content).toBe('LEGACY_SENTINEL_EPSILON\n'); + expect(res.headers['cache-control']).toBe('private, no-store'); + }); +}); diff --git a/backend/__tests__/unit/routes/pods.external-links.test.js b/backend/__tests__/unit/routes/pods.external-links.test.js index d705b5c5d..9a3151dc9 100644 --- a/backend/__tests__/unit/routes/pods.external-links.test.js +++ b/backend/__tests__/unit/routes/pods.external-links.test.js @@ -291,3 +291,31 @@ describe('pods routes - external links', () => { await request(app).get('/api/pods/p1/external-links').expect(404); }); }); + +describe('pods routes - WeChat QR image', () => { + let app; + let headerAtSend; + beforeEach(() => { + headerAtSend = undefined; + app = express(); + app.use(express.json()); + app.use((req, res, next) => { + // send() only adds its public default when no header is set, so record + // what the route had set at the moment it handed the file over. + res.sendFile = jest.fn(() => { + headerAtSend = res.getHeader('Cache-Control'); + res.status(200).end(); + }); + next(); + }); + app.use('/api/pods', routes); + jest.clearAllMocks(); + }); + + it('is sent private, no-store', async () => { + ExternalLink.findById.mockResolvedValue({ type: 'wechat', qrCodePath: '/uploads/qr.png', podId: 'p1' }); + Pod.findById.mockResolvedValue({ members: [{ toString: () => 'user1' }] }); + await request(app).get('/api/pods/external-link/l1/qrcode').expect(200); + expect(headerAtSend).toBe('private, no-store'); + }); +}); diff --git a/backend/__tests__/unit/routes/uploads.test.js b/backend/__tests__/unit/routes/uploads.test.js index 35042c7c0..e100578d4 100644 --- a/backend/__tests__/unit/routes/uploads.test.js +++ b/backend/__tests__/unit/routes/uploads.test.js @@ -85,6 +85,7 @@ describe('uploads GET /:fileName (ADR-002 Phase 1)', () => { }) .expect(200); expect(res.headers['content-type']).toMatch(/image\/png/); + expect(res.headers['cache-control']).toBe('private, no-store'); expect(Buffer.isBuffer(res.body) ? res.body : Buffer.from(res.body)).toEqual( Buffer.from('hello'), ); @@ -195,6 +196,7 @@ describe('uploads GET /:fileName (ADR-002 Phase 1)', () => { const res = await request(app).get('/api/uploads/legacy.jpg').expect(200); expect(res.headers['content-type']).toMatch(/image\/jpeg/); + expect(res.headers['cache-control']).toBe('private, no-store'); expect(res.body.toString()).toBe('legacy-bytes'); expect(File.findByFileName).toHaveBeenCalledWith('legacy.jpg'); }); diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 7666ff848..bbc59c2fa 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -2104,6 +2104,8 @@ router.get('/pods/:podId/files/:fileName/content', phase4RateLimit, agentRuntime if (!buffer) { return res.status(404).json({ message: 'File content not found' }); } + // Set explicitly on every response that carries file bytes, so the header never depends on a default. + res.set('Cache-Control', 'private, no-store'); const ct = String(file.contentType || ''); const isText = isTextualContentType(ct); diff --git a/backend/routes/pods.ts b/backend/routes/pods.ts index 86426d142..50ea88a7e 100644 --- a/backend/routes/pods.ts +++ b/backend/routes/pods.ts @@ -52,6 +52,7 @@ interface AuthReq { interface Res { status: (n: number) => Res; json: (d: unknown) => void; + set: (field: string, value: string) => void; sendFile: (path: string) => void; } @@ -324,6 +325,8 @@ router.get('/external-link/:linkId/qrcode', auth, async (req: AuthReq, res: Res) if (!link || link.type !== 'wechat' || !link.qrCodePath) return res.status(404).json({ message: 'QR code not found' }); const pod = await Pod.findById(link.podId) as { members?: Array<{ toString: () => string }> } | null; if (!pod || !pod.members?.some((m) => m.toString() === req.user?.id)) return res.status(403).json({ message: 'Access denied' }); + // Set explicitly: send() defaults to public, max-age=0 when no header is set. + res.set('Cache-Control', 'private, no-store'); return res.sendFile(path.resolve(link.qrCodePath)); } catch (error) { console.error('Error fetching QR code:', error); diff --git a/backend/routes/uploads.ts b/backend/routes/uploads.ts index 26c1219e2..94839f729 100644 --- a/backend/routes/uploads.ts +++ b/backend/routes/uploads.ts @@ -135,6 +135,8 @@ const setServedHeaders = (res: Res, fileName: string): void => { res.set('Content-Type', served.type); res.set('X-Content-Type-Options', 'nosniff'); res.set('Content-Disposition', served.inline ? 'inline' : 'attachment'); + // Set explicitly on every response that carries file bytes, so the header never depends on a default. + res.set('Cache-Control', 'private, no-store'); }; // Size cap is driven by the driver's declared max. multer returns 413 itself