Fix empty-body JSON error on body-less admin requests

The shared cloud req() helper set content-type: application/json on every
request, so body-less DELETE/revoke/rotate calls tripped Fastify's
"Body cannot be empty" 400. Only send the header when there's a body, and
(defensively) make the server parse an empty application/json body as
undefined instead of erroring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:06:38 +02:00
parent ab35b378af
commit ae676aa86c
2 changed files with 15 additions and 1 deletions
+10
View File
@@ -38,6 +38,16 @@ export function buildServer() {
// visitor shares one rate-limit bucket. Trust the proxy's X-Forwarded-For so
// req.ip is the real client. Only Traefik can reach the container, so this is safe.
const app = Fastify({ bodyLimit: BODY_LIMIT, trustProxy: true });
// Tolerate a body-less request that still sets content-type: application/json
// (browsers/fetch wrappers often do this for DELETE/POST-with-no-body). The
// default parser 400s on an empty JSON body; every handler treats a missing
// body as {}, so parse empty → undefined instead.
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const s = (body as string).trim();
if (!s) return done(null, undefined);
try { done(null, JSON.parse(s)); }
catch { const err = new Error('Invalid JSON') as Error & { statusCode?: number }; err.statusCode = 400; done(err, undefined); }
});
const hub = new RoomHub();
const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS, MAX_USERS);
void accounts.load();
+5 -1
View File
@@ -16,7 +16,11 @@ export interface CloudCharInfo { id: string; name: string; ownerUserId: string;
export async function req<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(`${base()}${path}`, { ...init, headers: { 'content-type': 'application/json', ...authHeaders(), ...(init?.headers ?? {}) } });
// Only declare a JSON content-type when we actually send a body — Fastify
// rejects an empty body that claims content-type: application/json, which
// broke the body-less DELETE/revoke/rotate calls.
const jsonType = init?.body != null ? { 'content-type': 'application/json' } : {};
res = await fetch(`${base()}${path}`, { ...init, headers: { ...jsonType, ...authHeaders(), ...(init?.headers ?? {}) } });
} catch {
throw new CloudError('Could not reach the server.');
}