Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/backend/controllers/auth/AuthController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2553,9 +2553,13 @@ describe('AuthController.handleCreateAccessToken + handleRevokeAccessToken', ()
['an object', {}],
['an array', ['30d']],
['a boolean', true],
['a fraction', 1.5],
['a negative', -60],
['an unparseable duration', 'banana'],
['an unknown unit', '30x'],
])('rejects %s as expiresIn with 400', async (_label, expiresIn) => {
// Only seconds or a duration string are valid; anything else reached
// the expiry parser and 500'd on `.trim`.
// Anything the signer can't read reaches it only after the session row
// is already inserted, so it has to be refused here.
await expect(
controller.handleCreateAccessToken(
makeReq(
Expand All @@ -2570,6 +2574,14 @@ describe('AuthController.handleCreateAccessToken + handleRevokeAccessToken', ()
).rejects.toMatchObject({ statusCode: 400 });
});

it('answers 400 when the request has no parsable body', async () => {
const req = makeReq({}, { actor });
(req as { body?: unknown }).body = undefined;
await expect(
controller.handleCreateAccessToken(req, makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});

it('rejects a permission spec that is neither a string nor a tuple with 400', async () => {
await expect(
controller.handleCreateAccessToken(
Expand Down Expand Up @@ -7356,7 +7368,7 @@ describe('AuthController.loginWait audience binding', () => {
});

it('answers 400 when the request has no parsable body', async () => {
// Destructuring an absent body is a TypeError, which surfaced as a 500.
// Destructuring an absent body is a TypeError, not a 400.
const req = makeReq({}, { headers: { origin: OPENER } });
(req as { body?: unknown }).body = undefined;
await expect(
Expand Down
31 changes: 17 additions & 14 deletions src/backend/controllers/auth/AuthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3779,7 +3779,7 @@ export class AuthController extends PuterController {
rateLimit: CREDENTIAL_MINT_LIMIT,
})
async handleCreateAccessToken(req: Request, res: Response): Promise<void> {
const { permissions, expiresIn, label } = req.body;
const { permissions, expiresIn, label } = req.body ?? {};
if (!Array.isArray(permissions) || permissions.length === 0) {
throw new HttpError(400, 'Missing or empty `permissions` array', {
legacyCode: 'bad_request',
Expand All @@ -3798,19 +3798,22 @@ export class AuthController extends PuterController {
normalizedLabel = label.trim().slice(0, 64) || null;
}

// jsonwebtoken takes seconds or a duration string ('30d'); anything
// else reaches the expiry parser as something with no `.trim`.
if (
expiresIn !== undefined &&
expiresIn !== null &&
typeof expiresIn !== 'string' &&
typeof expiresIn !== 'number'
) {
throw new HttpError(
400,
'`expiresIn` must be a number of seconds or a duration string',
{ legacyCode: 'bad_request' },
);
// Whole seconds or a duration string ('30d'). A wrong type, a
// fraction, or an unparseable unit all reach the signer, which throws
// after the session row is already inserted.
if (expiresIn !== undefined && expiresIn !== null) {
const usable =
typeof expiresIn === 'number'
? Number.isInteger(expiresIn) && expiresIn > 0
: typeof expiresIn === 'string' &&
/^\d+\s*[smhdwy]?$/.test(expiresIn.trim());
if (!usable) {
throw new HttpError(
400,
'`expiresIn` must be a whole number of seconds or a duration string like `30d`',
{ legacyCode: 'bad_request' },
);
}
}

// Normalize specs: string → [string], [string] → [string, {}], [string, extra] → as-is
Expand Down
11 changes: 7 additions & 4 deletions src/backend/controllers/fs/FSController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export class FSController extends PuterController {
? await Promise.all(
req.body.map(async (requestBody) => {
const normalizedRequestBody = this.#withGuiMetadata(
requestBody,
this.#requireObjectBody(requestBody),
req.body,
);
normalizedRequestBody.fileMetadata =
Expand Down Expand Up @@ -363,7 +363,10 @@ export class FSController extends PuterController {
const userId = this.#getActorUserId(req);
const requests = Array.isArray(req.body)
? req.body.map((requestBody) => {
return this.#withGuiMetadata(requestBody, req.body);
return this.#withGuiMetadata(
this.#requireObjectBody(requestBody),
req.body,
);
})
: [];
for (const requestBody of requests) {
Expand Down Expand Up @@ -850,7 +853,7 @@ export class FSController extends PuterController {
? await Promise.all(
req.body.map(async (requestBody) => {
const normalizedRequestBody = this.#withGuiMetadata(
requestBody,
this.#requireObjectBody(requestBody),
req.body,
);
normalizedRequestBody.fileMetadata =
Expand Down Expand Up @@ -2377,7 +2380,7 @@ export class FSController extends PuterController {
* that is a 500 where the request deserves a 400.
*/
#requireObjectBody<T>(body: T | undefined): T {
if (!body || typeof body !== 'object') {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new HttpError(400, 'A request body is required', {
legacyCode: 'bad_request',
});
Expand Down
43 changes: 41 additions & 2 deletions src/backend/controllers/fs/FSController.write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,8 +544,8 @@ describe('FSController.write', () => {
// server with the limit switched on.

describe('write handlers with no parsable body', () => {
// A request whose body never parsed leaves `req.body` undefined; reading
// fileMetadata off it used to 500 instead of answering 400.
// A request whose body never parsed leaves `req.body` undefined, and the
// handlers read fileMetadata straight off it.
it.each([
[
'write',
Expand All @@ -557,6 +557,11 @@ describe('write handlers with no parsable body', () => {
(c: typeof controller, r: Request, res: Response) =>
c.startWrite(r as never, res as never),
],
[
'completeWrite',
(c: typeof controller, r: Request, res: Response) =>
c.completeWrite(r as never, res as never),
],
])('%s answers 400', async (_label, call) => {
const { actor } = await makeUser();
const req = makeReq({ actor });
Expand All @@ -572,6 +577,40 @@ describe('write handlers with no parsable body', () => {
});
});

describe('batch write handlers with a malformed element', () => {
// The body is an array, so the array check passes; the elements are what
// the handlers then read fileMetadata / thumbnailData off.
it.each([
[
'startBatchWrites',
(c: typeof controller, r: Request, res: Response) =>
c.startBatchWrites(r as never, res as never),
],
[
'batchWrites',
(c: typeof controller, r: Request, res: Response) =>
c.batchWrites(r as never, res as never),
],
[
'completeBatchWrites',
(c: typeof controller, r: Request, res: Response) =>
c.completeBatchWrites(r as never, res as never),
],
])('%s answers 400 for a null element', async (_label, call) => {
const { actor } = await makeUser();
const req = makeReq({ actor });
(req as { body?: unknown }).body = [null];
const { res } = makeRes();

await expect(
withActor(actor, () => call(controller, req, res)),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
});
});
});

describe('FSController.write storage allowance', () => {
let limitedServer: PuterServer;
let limitedController: FSController;
Expand Down
11 changes: 11 additions & 0 deletions src/backend/drivers/ai-chat/utils/FunctionCalling.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import { HttpError } from '@heyputer/backend/src/core/http';

export const normalize_json_schema = (schema) => {
if (!schema) return schema;

Expand Down Expand Up @@ -137,6 +139,15 @@ export const make_openai_tools = (tools) => {
export const make_claude_tools = (tools) => {
if (!tools) return undefined;
return tools.map((tool) => {
// A TypeError here carries no status, so it is read as a provider
// failure and marks the route unhealthy for everyone.
if (!tool?.function) {
throw new HttpError(
400,
"each tool must have a 'function' property",
{ legacyCode: 'bad_request' },
);
}
const { name, description, parameters } = tool.function;
return {
name,
Expand Down
20 changes: 17 additions & 3 deletions src/backend/drivers/ai-chat/utils/FunctionCalling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,7 @@ describe('normalize_tools_object', () => {
});

it('mutates the array in place and returns the same reference', () => {
const tools = [
{ name: 'lookup', input_schema: { type: 'object' } },
];
const tools = [{ name: 'lookup', input_schema: { type: 'object' } }];
const out = normalize_tools_object(tools);
expect(out).toBe(tools);
});
Expand All @@ -196,6 +194,22 @@ describe('make_openai_tools', () => {
});
});

describe('make_claude_tools with a malformed tool', () => {
it.each([
['no function property', { type: 'web_search' }],
['a null entry', null],
])('answers 400 for a tool with %s', (_label, tool) => {
// A TypeError here has no status, so the retry loop reads it as a
// provider failure and marks the route unhealthy for every caller.
expect(() => make_claude_tools([tool] as never)).toThrowError(
expect.objectContaining({
statusCode: 400,
legacyCode: 'bad_request',
}),
);
});
});

// ── make_claude_tools ───────────────────────────────────────────────

describe('make_claude_tools', () => {
Expand Down
9 changes: 9 additions & 0 deletions src/backend/drivers/ai-chat/utils/Messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ export const normalize_single_message = (message, params = {}) => {
message.content = [];
for (let i = 0; i < message.tool_calls.length; i++) {
const tool_call = message.tool_calls[i];
// Streaming deltas and non-OpenAI tool-call shapes both omit
// `function`, so it cannot be assumed present.
if (!tool_call?.function) {
throw new HttpError(
400,
"each tool_call must have a 'function' property",
{ legacyCode: 'bad_request' },
);
}
message.content.push({
type: 'tool_use',
id: tool_call.id,
Expand Down
41 changes: 38 additions & 3 deletions src/backend/drivers/ai-chat/utils/Messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ describe('normalize_single_message', () => {
});

it('throws 400 when no content + no tool_calls (and not a tool message)', () => {
expect(() =>
normalize_single_message({ role: 'assistant' }),
).toThrow(expect.objectContaining({ statusCode: 400 }));
expect(() => normalize_single_message({ role: 'assistant' })).toThrow(
expect.objectContaining({ statusCode: 400 }),
);
});

it('synthesizes content from tool_calls when content is missing', () => {
Expand Down Expand Up @@ -192,6 +192,41 @@ describe('normalize_single_message', () => {
});
});

describe('normalize_single_message tool_calls', () => {
it.each([
['no function property', { id: 'x' }],
['a type-only entry', { type: 'function' }],
['a null entry', null],
])('answers 400 for a tool_call with %s', (_label, toolCall) => {
// Reachable straight off a /drivers/call body, and it throws before
// the provider call, so it surfaces as a bare 500 otherwise.
expect(() =>
normalize_messages([
{ role: 'assistant', tool_calls: [toolCall] },
] as never),
).toThrowError(
expect.objectContaining({
statusCode: 400,
legacyCode: 'bad_request',
}),
);
});

it('still converts a well-formed tool_call', () => {
const result = normalize_messages([
{
role: 'assistant',
tool_calls: [
{ id: 'c1', function: { name: 'lookup', arguments: '{}' } },
],
},
] as never);
expect(result[0].content).toEqual([
{ type: 'tool_use', id: 'c1', name: 'lookup', input: '{}' },
]);
});
});

// ── normalize_messages ──────────────────────────────────────────────

describe('normalize_messages', () => {
Expand Down
21 changes: 16 additions & 5 deletions src/backend/drivers/ai-image/ImageGenerationDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { ReplicateImageGenerationProvider } from './providers/replicate/Replicat
import { TogetherImageProvider } from './providers/together/TogetherImageProvider.js';
import { XAIImageProvider } from './providers/xai/XAIImageProvider.js';
import type { IGenerateParams, IImageModel, IImageProvider } from './types.js';
import { assertInputImagesShape } from './inputImage.js';

/**
* Driver implementing the `puter-image-generation` interface.
Expand Down Expand Up @@ -127,6 +128,11 @@ export class ImageGenerationDriver extends PuterDriver {
legacyCode: 'unauthorized',
});

// Every provider reads these off `args`, and several index into them
// directly rather than through the shared helpers, so the shape is
// settled here — once — before any provider runs.
assertInputImagesShape(args, 'image generation');

const puterOutputPath = args.puter_output_path;
delete args.puter_output_path;

Expand Down Expand Up @@ -293,7 +299,8 @@ export class ImageGenerationDriver extends PuterDriver {
const cloudflare = (providers['cloudflare-image-generation'] ??
providers['cloudflare-workers-ai-image'] ??
providers['cloudflare-workers-ai']) as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const cfToken =
(cloudflare?.apiToken as string | undefined) ??
(cloudflare?.apiKey as string | undefined) ??
Expand All @@ -308,7 +315,8 @@ export class ImageGenerationDriver extends PuterDriver {
apiToken: cfToken,
accountId: cfAccount,
apiBaseUrl: cloudflare?.apiBaseUrl as
string | undefined,
| string
| undefined,
},
m,
);
Expand Down Expand Up @@ -340,9 +348,11 @@ export class ImageGenerationDriver extends PuterDriver {
// pair its missing apiBaseUrl with the shared block's key (or vice
// versa) and point a region-scoped key at the wrong endpoint.
const byteplusImageCfg = providers['byteplus-image-generation'] as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const byteplusSharedCfg = providers['byteplus'] as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const byteplusKey = readKey(byteplusImageCfg, byteplusSharedCfg);
if (byteplusKey) {
this.#providers['byteplus-image-generation'] =
Expand All @@ -351,7 +361,8 @@ export class ImageGenerationDriver extends PuterDriver {
apiKey: byteplusKey,
apiBaseUrl: (byteplusImageCfg?.apiBaseUrl ??
byteplusSharedCfg?.apiBaseUrl) as
string | undefined,
| string
| undefined,
},
m,
);
Expand Down
Loading
Loading