fix(backend): restore the large-body limit on the voice route

POST /voice/extract returned 500 for any real recording. The threshold was
exactly 100 kb — Express's body-parser default — which is about 20 seconds of
audio, so the endpoint was unusable at its own 2-minute cap.

The scoped parser was registered as a path-mounted json() stacked in front of a
default one, which relied on two implicit behaviours: Express stripping the
mount path, and body-parser skipping a request another parser had already
handled. That coupling broke when the surrounding middleware order shifted, and
it broke silently — the parser was still registered, just no longer the one that
ran. Bisected by dumping the Express layer stack and confirming the raw error was
`entity.too.large` with `limit: 102400`.

Replaced with a single middleware that picks a parser by path. No mount-path
stripping, no dependence on parser ordering. Extracted to common/body-parsers.ts
so it is covered by a unit test rather than only reachable through main.ts, which
createTestingModule never executes.

The test is mutation-checked: forcing the default parser fails 2 of its 5 cases.
It also pins that the larger limit does not leak app-wide, and that a merely
similar path (/api/voice/extract/extra) does not get it.

Verified against the compiled server: 300 kb now reaches /api/voice/extract,
/api/auth/login still rejects it, and ordinary requests are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 22:12:16 +03:30
parent 7b50134d03
commit 2a42f20bff
3 changed files with 126 additions and 7 deletions

View File

@@ -0,0 +1,87 @@
import express, {
type NextFunction,
type Request,
type Response,
} from 'express';
import request from 'supertest';
import { createJsonBodyParser, VOICE_EXTRACT_PATH } from './body-parsers';
/**
* Guards a bug that made the voice endpoint completely unusable while surfacing as a
* generic 500: the large-body limit stopped applying, so every real recording — anything
* past roughly 20 seconds of audio — was rejected by Express's 100 kb default.
*/
type ProbeBody = { keys?: number; type?: string };
function buildApp(): express.Express {
const app = express();
app.use(createJsonBodyParser());
app.post('*splat', (req: Request, res: Response) => {
res.json({ keys: Object.keys((req.body ?? {}) as object).length });
});
// Surface body-parser's own error instead of Express's HTML default page.
app.use(
(
err: { status?: number; type?: string },
_req: Request,
res: Response,
next: NextFunction,
) => {
if (res.headersSent) {
next(err);
return;
}
res.status(err.status ?? 500).json({ type: err.type });
},
);
return app;
}
const bodyOfKb = (kb: number) => ({ audio: 'A'.repeat(kb * 1024) });
describe('createJsonBodyParser', () => {
it('accepts a body far past the default limit on the voice route', async () => {
const res = await request(buildApp())
.post(VOICE_EXTRACT_PATH)
.send(bodyOfKb(300));
expect(res.status).toBe(200);
expect((res.body as ProbeBody).keys).toBe(1);
});
it('accepts a realistic worst-case recording', async () => {
// Two minutes of opus is well under 1 MB, but wav is far larger; 4 MB must pass.
const res = await request(buildApp())
.post(VOICE_EXTRACT_PATH)
.send(bodyOfKb(4096));
expect(res.status).toBe(200);
});
it('keeps the default limit on every other route', async () => {
// The larger limit must not leak app-wide as a side effect.
const res = await request(buildApp())
.post('/api/auth/login')
.send(bodyOfKb(300));
expect(res.status).toBe(413);
expect((res.body as ProbeBody).type).toBe('entity.too.large');
});
it('still parses ordinary bodies on ordinary routes', async () => {
const res = await request(buildApp())
.post('/api/auth/login')
.send({ email: 'a@b.c' });
expect(res.status).toBe(200);
expect((res.body as ProbeBody).keys).toBe(1);
});
it('does not widen the limit for a path that merely looks similar', async () => {
for (const path of [
'/api/voice/extract/extra',
'/api/voice',
'/voice/extract',
]) {
const res = await request(buildApp()).post(path).send(bodyOfKb(300));
expect(res.status).toBe(413);
}
});
});

View File

@@ -0,0 +1,35 @@
import {
json,
type NextFunction,
type Request,
type RequestHandler,
type Response,
} from 'express';
/** The one route that accepts a large body, and how large. */
export const VOICE_EXTRACT_PATH = '/api/voice/extract';
export const VOICE_BODY_LIMIT = '10mb';
/**
* JSON body parsing for the whole app.
*
* Voice recordings are base64 JSON and pass Express's 100 kb default at roughly 20 seconds
* of audio, so that one route needs a larger limit while every other endpoint keeps the
* default — a large body should not become acceptable everywhere.
*
* Deliberately a single middleware that *chooses* a parser, rather than a path-mounted
* parser stacked in front of a default one. That arrangement relied on Express's
* mount-path stripping plus body-parser skipping an already-parsed request, and it
* silently stopped applying when the surrounding middleware order shifted — at which point
* the endpoint rejected every real recording with a 500. One explicit branch has no such
* coupling, and is covered by body-parsers.spec.ts.
*/
export function createJsonBodyParser(): RequestHandler {
const voiceParser = json({ limit: VOICE_BODY_LIMIT });
const defaultParser = json();
return (req: Request, res: Response, next: NextFunction) =>
req.path === VOICE_EXTRACT_PATH
? voiceParser(req, res, next)
: defaultParser(req, res, next);
}

View File

@@ -1,7 +1,8 @@
// backend/src/main.ts
import { NestFactory } from '@nestjs/core';
import { json, urlencoded } from 'express';
import { urlencoded } from 'express';
import { AppModule } from './app.module';
import { createJsonBodyParser } from './common/body-parsers';
import { ValidationPipe } from '@nestjs/common';
import cookieParser from 'cookie-parser'; // 👈 Change this line!
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
@@ -29,12 +30,8 @@ async function bootstrap() {
// reject a voice recording at its 100 kb default before any later middleware ran.
const app = await NestFactory.create(AppModule, { bodyParser: false });
// Voice recordings are base64 JSON and pass 100 kb at roughly 20 seconds of audio.
// Registered first and scoped to the one route: body-parser marks the request handled,
// so the default-limit parser below skips it and every other endpoint keeps the
// standard limit.
app.use('/api/voice/extract', json({ limit: '10mb' }));
app.use(json());
// Voice needs a larger JSON limit than everything else; see body-parsers.ts.
app.use(createJsonBodyParser());
app.use(urlencoded({ extended: true }));
app.useGlobalFilters(new HttpExceptionFilter());