fix(api): align combo body and legacy key access (#12070)

Alinha o body do combo e o acesso legado por chave, com testes atualizados (CLI api-generator + row parsers). Validado no worktree combinado. Obrigado!
This commit is contained in:
Marcelo Karval
2026-08-30 11:10:06 -03:00
committed by GitHub
parent 09428da3d9
commit 82f09f4c86
8 changed files with 91 additions and 10 deletions

View File

@@ -16,7 +16,7 @@ export function register_combos(parent) {
});
tag.command("post-api-combos")
.description("Create routing combo")
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
.requiredOption("--body <jsonOrPath>", "JSON body or @path/to/file.json")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos";
@@ -44,7 +44,7 @@ export function register_combos(parent) {
tag.command("put-api-combos-id-")
.description("Update combo")
.requiredOption("--id <id>", "")
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
.requiredOption("--body <jsonOrPath>", "JSON body or @path/to/file.json")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/{id}";
@@ -62,7 +62,7 @@ export function register_combos(parent) {
tag.command("patch-api-combos-id-")
.description("Update combo")
.requiredOption("--id <id>", "")
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
.requiredOption("--body <jsonOrPath>", "JSON body or @path/to/file.json")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/{id}";
@@ -99,10 +99,17 @@ export function register_combos(parent) {
});
tag.command("post-api-combos-test")
.description("Test a combo configuration")
.requiredOption("--body <jsonOrPath>", "JSON body or @path/to/file.json")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/test";
const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
let body;
if (opts.body) {
body = opts.body.startsWith("@")
? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))
: JSON.parse(opts.body);
}
const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);
});

View File

@@ -0,0 +1 @@
- **fix(api):** Generated API CLI commands now enforce required OpenAPI request bodies; Combo test commands forward the required `comboName` body, while API keys created by older writers after migration 149 preserve legacy allow-all Combo access without widening explicit empty allowlists — thanks @marcelokarval

View File

@@ -2350,9 +2350,24 @@ paths:
post:
tags: [Combos]
summary: Test a combo configuration
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [comboName]
properties:
comboName:
type: string
minLength: 1
responses:
"200":
description: Test result
"400":
description: Missing or invalid combo name
"404":
description: Combo not found
/api/settings:
get:

View File

@@ -1936,9 +1936,24 @@ paths:
post:
tags: [Combos]
summary: Test a combo configuration
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [comboName]
properties:
comboName:
type: string
minLength: 1
responses:
"200":
description: Test result
"400":
description: Missing or invalid combo name
"404":
description: Combo not found
/api/settings:
get:

View File

@@ -130,7 +130,10 @@ for (const [tag, ops] of Object.entries(byTag)) {
lines.push(` .${flag}("--${kebab(p.name)} <${p.name}>", "${escapeStr(p.description)}")`);
}
if (hasBody) {
lines.push(` .option("--body <jsonOrPath>", "JSON body or @path/to/file.json")`);
const bodyFlag = op.requestBody.required ? "requiredOption" : "option";
lines.push(
` .${bodyFlag}("--body <jsonOrPath>", "JSON body or @path/to/file.json")`
);
}
lines.push(` .action(async (opts, cmd) => {`);
lines.push(` const gOpts = cmd.optsWithGlobals();`);

View File

@@ -9,6 +9,7 @@
*/
import type { AccessSchedule, RateLimitRule } from "./types";
import { ALL_COMBOS_ACCESS_RULE } from "@/shared/constants/comboAccess";
export { parseModelAccessMode } from "./modelAccessMode";
export type { ModelAccessMode } from "./modelAccessMode";
@@ -30,6 +31,9 @@ export function parseAllowedModels(value: unknown): string[] {
}
export function parseAllowedCombos(value: unknown): string[] {
// Migration 149 may already be recorded before an older writer creates a key.
// Preserve those legacy NULL rows as allow-all while keeping explicit [] deny-all.
if (value === null || value === undefined) return [ALL_COMBOS_ACCESS_RULE];
return parseStringList(value);
}

View File

@@ -39,6 +39,13 @@ test("parseAllowedModels keeps only string entries, tolerates junk", () => {
assert.deepEqual(P.parseAllowedModels(null), []);
});
test("parseAllowedCombos preserves legacy NULL as allow-all without widening explicit []", () => {
assert.deepEqual(P.parseAllowedCombos(null), ["combo/*"]);
assert.deepEqual(P.parseAllowedCombos(undefined), ["combo/*"]);
assert.deepEqual(P.parseAllowedCombos("[]"), []);
assert.deepEqual(P.parseAllowedCombos('["fast-chat"]'), ["fast-chat"]);
});
test("flag parsers honor the 0/1/true/false matrix", () => {
assert.equal(P.parseNoLog(1), true);
assert.equal(P.parseNoLog("1"), true);

View File

@@ -40,6 +40,18 @@ paths:
responses:
"200":
description: Updated widget
/api/widgets/preview:
post:
tags: [Widgets]
summary: Preview widget
requestBody:
content:
application/json:
schema:
type: object
responses:
"200":
description: Previewed widget
components:
parameters:
ResourceId:
@@ -83,11 +95,16 @@ test("generator resolves a $ref path parameter into --id and substitutes {id} in
);
assert.doesNotMatch(generated, /url = "\/api\/widgets\/\{id\}";\s*\n\s*const res/);
// requestBody presence must still produce --body.
// Required and optional request bodies must preserve their OpenAPI semantics.
assert.match(
generated,
/\.option\("--body <jsonOrPath>"/,
"generated command must declare --body for the requestBody"
/tag\.command\("patch-api-widgets-id-?"\)[\s\S]*?\.requiredOption\("--body <jsonOrPath>"/,
"generated command must require --body for a required requestBody"
);
assert.match(
generated,
/tag\.command\("post-api-widgets-preview"\)[\s\S]*?\.option\("--body <jsonOrPath>"/,
"generated command must keep --body optional for an optional requestBody"
);
} finally {
rmSync(workDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
@@ -145,8 +162,8 @@ test("real generated bin/cli/api-commands/combos.mjs has --id and --body on the
);
assert.match(
patchBlock,
/\.option\("--body <jsonOrPath>"/,
"PATCH combo command must accept --body"
/\.requiredOption\("--body <jsonOrPath>"/,
"PATCH combo command must require --body"
);
assert.match(
patchBlock,
@@ -154,3 +171,15 @@ test("real generated bin/cli/api-commands/combos.mjs has --id and --body on the
"PATCH combo command must substitute {id} in the URL, not send it literally"
);
});
test("real generated combo-test command accepts and forwards its required request body", () => {
const src = readFileSync(REAL_COMBOS, "utf8");
const testBlockMatch = src.match(
/ {2}tag\.command\("post-api-combos-test"\)[\s\S]*?(?=\n {2}tag\.command\(|\n\})/
);
assert.ok(testBlockMatch, "combos.mjs must have a generated combo-test command block");
const testBlock = testBlockMatch[0];
assert.match(testBlock, /\.requiredOption\("--body <jsonOrPath>"/);
assert.match(testBlock, /const res = await apiFetch\(url, \{ method: "POST", body,/);
});