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
41 changes: 3 additions & 38 deletions src/app/api/services/9router/install/route.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,6 @@
import { z } from "zod";
import { install, InstallResult } from "@/lib/services/installers/ninerouter";
import { InstallError, SERVICE_VERSION_PATTERN } from "@/lib/services/installers/utils";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";

const BodySchema = z.object({
version: z
.string()
.regex(SERVICE_VERSION_PATTERN, "Invalid version: only letters, digits and . _ + - are allowed")
.optional()
.default("latest"),
});
import { install } from "@/lib/services/installers/ninerouter";
import { handleServiceInstall } from "@/app/api/services/_shared/installRoute";

export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = request.body === null ? {} : await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}

const parsed = BodySchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({ status: 400, message: parsed.error.message });
}

try {
const result: InstallResult = await install(parsed.data.version);
return Response.json({ ok: true, ...result });
} catch (err) {
if (err instanceof InstallError) {
return createErrorResponse({
status: err.httpStatus,
message: err.friendly,
});
}
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
return handleServiceInstall(request, install);
}
79 changes: 79 additions & 0 deletions src/app/api/services/_shared/installRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { z } from "zod";

import { createErrorResponse } from "@/lib/api/errorResponse";
import { InstallError, SERVICE_VERSION_PATTERN } from "@/lib/services/installers/utils";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";

export type ServiceInstallResult = {
installedVersion: string;
installPath: string;
durationMs: number;
};

export type ServiceInstaller = (version: string) => Promise<ServiceInstallResult>;

const installBodySchema = z.object({
// Keep the version constrained by SERVICE_VERSION_PATTERN — the per-route schemas
// enforced this before the extraction (#5474); dropping it here would let strings
// like "../../malicious" reach the installer (#5495).
version: z
.string()
.regex(SERVICE_VERSION_PATTERN, "Invalid version: only letters, digits and . _ + - are allowed")
.optional()
.default("latest"),
});

export async function readServiceInstallVersion(request: Request): Promise<
| {
ok: true;
version: string;
}
| {
ok: false;
response: Response;
}
> {
let body: unknown;
try {
body = request.body === null ? {} : await request.json();
} catch {
return {
ok: false,
response: createErrorResponse({ status: 400, message: "Invalid JSON body" }),
};
}

const parsed = installBodySchema.safeParse(body);
if (!parsed.success) {
return {
ok: false,
response: createErrorResponse({ status: 400, message: parsed.error.message }),
};
}

return { ok: true, version: parsed.data.version };
}

export async function handleServiceInstall(
request: Request,
install: ServiceInstaller
): Promise<Response> {
const parsed = await readServiceInstallVersion(request);
if (!parsed.ok) {
return parsed.response;
}

try {
const result = await install(parsed.version);
return Response.json({ ok: true, ...result });
} catch (err) {
if (err instanceof InstallError) {
return createErrorResponse({
status: err.httpStatus,
message: err.friendly,
});
}
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
}
38 changes: 3 additions & 35 deletions src/app/api/services/cliproxy/install/route.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,6 @@
import { z } from "zod";
import { install, InstallResult } from "@/lib/services/installers/cliproxy";
import { InstallError, SERVICE_VERSION_PATTERN } from "@/lib/services/installers/utils";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";

const BodySchema = z.object({
version: z
.string()
.regex(SERVICE_VERSION_PATTERN, "Invalid version: only letters, digits and . _ + - are allowed")
.optional()
.default("latest"),
});
import { install } from "@/lib/services/installers/cliproxy";
import { handleServiceInstall } from "@/app/api/services/_shared/installRoute";

export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = request.body === null ? {} : await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}

const parsed = BodySchema.safeParse(body);
if (!parsed.success) {
return createErrorResponse({ status: 400, message: parsed.error.message });
}

try {
const result: InstallResult = await install(parsed.data.version);
return Response.json({ ok: true, ...result });
} catch (err) {
if (err instanceof InstallError) {
return createErrorResponse({ status: err.httpStatus, message: err.friendly });
}
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 500, message: msg });
}
return handleServiceInstall(request, install);
}
121 changes: 121 additions & 0 deletions tests/unit/services/install-route-helper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import {
handleServiceInstall,
readServiceInstallVersion,
} from "../../../src/app/api/services/_shared/installRoute.ts";
import { InstallError } from "../../../src/lib/services/installers/utils.ts";

async function readJson(response: Response) {
return (await response.json()) as Record<string, unknown>;
}

test("readServiceInstallVersion defaults empty requests to latest", async () => {
const result = await readServiceInstallVersion(
new Request("http://localhost/api/services/example/install", { method: "POST" })
);

assert.deepEqual(result, { ok: true, version: "latest" });
});

test("readServiceInstallVersion returns the requested version", async () => {
const result = await readServiceInstallVersion(
new Request("http://localhost/api/services/example/install", {
method: "POST",
body: JSON.stringify({ version: "1.2.3" }),
headers: { "Content-Type": "application/json" },
})
);

assert.deepEqual(result, { ok: true, version: "1.2.3" });
});

test("readServiceInstallVersion rejects malformed versions (#5495 SERVICE_VERSION_PATTERN guard)", async () => {
const result = await readServiceInstallVersion(
new Request("http://localhost/api/services/example/install", {
method: "POST",
body: JSON.stringify({ version: "../../malicious" }),
headers: { "Content-Type": "application/json" },
})
);

assert.equal(result.ok, false);
if (result.ok) throw new Error("expected version validation failure");
assert.equal(result.response.status, 400);
});

test("handleServiceInstall never reaches the installer for a malformed version (#5495)", async () => {
const calls: string[] = [];
const response = await handleServiceInstall(
new Request("http://localhost/api/services/example/install", {
method: "POST",
body: JSON.stringify({ version: "v1; rm -rf /" }),
headers: { "Content-Type": "application/json" },
}),
async (version) => {
calls.push(version);
return { installedVersion: version, installPath: "/tmp/service", durationMs: 1 };
}
);

assert.deepEqual(calls, []);
assert.equal(response.status, 400);
});

test("readServiceInstallVersion preserves invalid JSON error shape", async () => {
const result = await readServiceInstallVersion(
new Request("http://localhost/api/services/example/install", {
method: "POST",
body: "not-json",
headers: { "Content-Type": "application/json" },
})
);

assert.equal(result.ok, false);
if (result.ok) throw new Error("expected parse failure");
assert.equal(result.response.status, 400);
const body = await readJson(result.response);
assert.equal((body.error as Record<string, unknown>).message, "Invalid JSON body");
});

test("handleServiceInstall wraps successful installer results", async () => {
const calls: string[] = [];
const response = await handleServiceInstall(
new Request("http://localhost/api/services/example/install", {
method: "POST",
body: JSON.stringify({ version: "2.0.0" }),
headers: { "Content-Type": "application/json" },
}),
async (version) => {
calls.push(version);
return {
installedVersion: version,
installPath: "/tmp/service",
durationMs: 42,
};
}
);

assert.deepEqual(calls, ["2.0.0"]);
assert.equal(response.status, 200);
assert.deepEqual(await readJson(response), {
ok: true,
installedVersion: "2.0.0",
installPath: "/tmp/service",
durationMs: 42,
});
});

test("handleServiceInstall maps InstallError to its friendly message and status", async () => {
const response = await handleServiceInstall(
new Request("http://localhost/api/services/example/install", { method: "POST" }),
async () => {
throw new InstallError("raw command failed", "Friendly install failure", 503);
}
);

assert.equal(response.status, 503);
const body = await readJson(response);
assert.equal((body.error as Record<string, unknown>).message, "Friendly install failure");
});
Loading