mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
Compare commits
1 Commits
fix/v3850-
...
fix/better
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30cac12f5e |
@@ -180,6 +180,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(build):** every route no longer answers HTTP 500 on artifacts built from the release tip ([#11343](https://github.com/diegosouzapw/OmniRoute/issues/11343)) — `next.config.mjs` aliased `better-sqlite3` to its build-time stub **unconditionally**, on the premise that `serverExternalPackages` still won at runtime. It does not: a Turbopack `resolveAlias` rewrites the request *before* the externals check, so the request stopped matching the `better-sqlite3` external entry and the stub was baked into the shipped bundle. The sync driver then failed with `r(...) is not a constructor`, fell through `node:sqlite` and sql.js, and the instrumentation hook aborted at boot. Same failure shape as [#6344](https://github.com/diegosouzapw/OmniRoute/issues/6344), so it gets the same treatment: the alias is opt-in via `OMNIROUTE_BETTER_SQLITE3_STUB=1` through the shared `scripts/build/better-sqlite3-stub-flag.mjs` helper — set it only on a build host that actually hits the SIGABRT build-worker teardown ([#10060](https://github.com/diegosouzapw/OmniRoute/issues/10060)); default builds externalize the real native addon. Regression guards: `tests/unit/better-sqlite3-stub-alias-11343.test.mjs` (5) and the env matrix in `tests/unit/next-config.test.ts`.
|
||||
- **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963
|
||||
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
|
||||
- **cli**: route provider test commands through configured connection test endpoints (#10570)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- **docs(openapi):** document the conditionally management-authenticated, same-origin `POST /api/openapi/try` proxy contract and restore the release branch's operation-coverage ratchet ([#11363](https://github.com/diegosouzapw/OmniRoute/pull/11363))
|
||||
@@ -6931,104 +6931,6 @@ paths:
|
||||
"500":
|
||||
description: Failed to parse OpenAPI spec
|
||||
|
||||
/api/openapi/try:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Proxy an API Explorer request to an OmniRoute endpoint
|
||||
description: >-
|
||||
Executes an API Explorer request through a server-side, same-origin proxy. The target
|
||||
must start with `/api/`, `/v1/`, `/v1beta/`, `/a2a`, or
|
||||
`/.well-known/agent.json`; protocol-relative and cross-origin targets are rejected.
|
||||
Hop-by-hop, proxy, host, cookie, and forwarding headers supplied in `headers` are
|
||||
stripped, while any dashboard cookie on the original request is forwarded separately.
|
||||
When `requireLogin` is disabled, the management-auth bypass mirrors the runtime setting;
|
||||
otherwise a management Bearer credential or dashboard session is required. Failures
|
||||
caught after authentication, including request JSON parsing, fetch, and response-body
|
||||
parsing failures, are returned in the normal HTTP 200 result envelope so the Explorer
|
||||
can display them; `status: 0` identifies that caught-failure path.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ManagementSessionAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [path]
|
||||
properties:
|
||||
method:
|
||||
type: string
|
||||
enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS]
|
||||
default: GET
|
||||
path:
|
||||
type: string
|
||||
minLength: 1
|
||||
pattern: "^/(?:api/|v1/|v1beta/|a2a|\\.well-known/agent\\.json)"
|
||||
description: Same-origin OmniRoute API path, optionally including a query string.
|
||||
headers:
|
||||
type: object
|
||||
default: {}
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: >-
|
||||
Headers to forward after removing connection, content-length, cookie, host,
|
||||
keep-alive, proxy-authenticate, proxy-authorization, te, trailer,
|
||||
transfer-encoding, upgrade, x-forwarded-for, x-forwarded-host, and
|
||||
x-forwarded-proto headers.
|
||||
body:
|
||||
description: >-
|
||||
Optional JSON value. A truthy value is serialized unless it is already a
|
||||
string, and is not forwarded when `method` is `GET`.
|
||||
responses:
|
||||
"200":
|
||||
description: Upstream response or displayable caught-failure envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [status, statusText, headers, body, latencyMs, contentType]
|
||||
properties:
|
||||
status:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Upstream HTTP status, or 0 when request processing throws.
|
||||
statusText:
|
||||
type: string
|
||||
headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
body:
|
||||
description: >-
|
||||
Parsed JSON, response text truncated after 10,000 characters, or a sanitized
|
||||
caught-error object.
|
||||
latencyMs:
|
||||
type: integer
|
||||
minimum: 0
|
||||
contentType:
|
||||
type: string
|
||||
"400":
|
||||
description: Invalid request body or non-same-origin path
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ValidationErrorResponse"
|
||||
- type: object
|
||||
required: [error]
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
example: Path must be same-origin
|
||||
"401":
|
||||
$ref: "#/components/responses/ManagementAuthenticationRequired"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagementInvalidToken"
|
||||
"503":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
# ─── Agent Skills Catalog ────────────────────────────────────────────────────
|
||||
|
||||
/api/agent-skills:
|
||||
|
||||
@@ -2,6 +2,7 @@ import createNextIntlPlugin from "next-intl/plugin";
|
||||
import { createMDX } from "fumadocs-mdx/next";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { betterSqlite3AliasFor } from "./scripts/build/better-sqlite3-stub-flag.mjs";
|
||||
import { mitmManagerAliasFor } from "./scripts/build/mitm-stub-flag.mjs";
|
||||
import { normalizeBasePath } from "./scripts/build/normalizeBasePath.mjs";
|
||||
import {
|
||||
@@ -138,10 +139,14 @@ const nextConfig = {
|
||||
// the stub to every npm/Electron/VPS artifact and broke Agent Bridge
|
||||
// start for all non-Docker users (#6344). See scripts/build/mitm-stub-flag.mjs.
|
||||
...mitmManagerAliasFor(process.env),
|
||||
// Build-time stub so the bundler never traces the native better-sqlite3
|
||||
// addon into a build worker (SIGABRT at worker teardown). Runtime still
|
||||
// uses the real package via serverExternalPackages. (#10060)
|
||||
"better-sqlite3": "./src/lib/db/better-sqlite3.stub.js",
|
||||
// better-sqlite3 → build-time stub ONLY where the build worker actually
|
||||
// aborts while tracing the native addon (SIGABRT at worker teardown,
|
||||
// #10060); opt in with OMNIROUTE_BETTER_SQLITE3_STUB=1. The alias used to
|
||||
// be unconditional on the premise that serverExternalPackages still won
|
||||
// at runtime — it does not: resolveAlias rewrites the request before the
|
||||
// externals check, so the stub was bundled and EVERY route answered 500
|
||||
// (#11343). See scripts/build/better-sqlite3-stub-flag.mjs.
|
||||
...betterSqlite3AliasFor(process.env),
|
||||
...minimalBuildAliases,
|
||||
},
|
||||
// src/lib/agentSkills/generator.ts builds its fs base path from a runtime
|
||||
|
||||
36
scripts/build/better-sqlite3-stub-flag.mjs
Normal file
36
scripts/build/better-sqlite3-stub-flag.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Decide whether the Next.js build should alias `better-sqlite3` to the
|
||||
* build-time stub (src/lib/db/better-sqlite3.stub.js).
|
||||
*
|
||||
* History (#11343): the alias was UNCONDITIONAL, added to keep the bundler from
|
||||
* tracing the native addon into a Next.js build worker, whose thread teardown
|
||||
* can abort with SIGABRT (assertion in node::RemoveEnvironmentCleanupHook) and
|
||||
* leave the build without standalone output (#10060).
|
||||
*
|
||||
* The premise recorded next to that alias — "runtime still uses the real
|
||||
* package via serverExternalPackages" — does not hold. A Turbopack
|
||||
* `resolveAlias` rewrites the request BEFORE the externals check runs, so
|
||||
* `better-sqlite3` becomes a relative path, no longer matches the
|
||||
* `serverExternalPackages` entry, and the stub is baked into the bundle. Every
|
||||
* artifact built from that config answered HTTP 500 on every route: the stub's
|
||||
* default export is not a constructor, the sync driver chain fell through to
|
||||
* `node:sqlite` and then sql.js, and the instrumentation hook aborted at boot.
|
||||
*
|
||||
* This is the same failure shape as #6344 (the @/mitm/manager stub shipping to
|
||||
* every npm/Electron/VPS artifact), so it gets the same treatment: the alias is
|
||||
* opt-in, and a default build gets the real, externalized native package.
|
||||
*
|
||||
* Set OMNIROUTE_BETTER_SQLITE3_STUB=1 ONLY on a build host that actually hits
|
||||
* the SIGABRT worker teardown, and never for an artifact that will be run —
|
||||
* the resulting bundle cannot open a database.
|
||||
*/
|
||||
export function shouldStubBetterSqlite3(env = process.env) {
|
||||
return env.OMNIROUTE_BETTER_SQLITE3_STUB === "1";
|
||||
}
|
||||
|
||||
/** Turbopack resolveAlias fragment for `better-sqlite3`, derived from the env. */
|
||||
export function betterSqlite3AliasFor(env = process.env) {
|
||||
return shouldStubBetterSqlite3(env)
|
||||
? { "better-sqlite3": "./src/lib/db/better-sqlite3.stub.js" }
|
||||
: {};
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
// Build-time stub for better-sqlite3 (#10060).
|
||||
//
|
||||
// Aliased in for the Next.js production build (turbopack + webpack) so the
|
||||
// bundler never pulls the real native addon into a build worker. The native
|
||||
// Statement destructor aborts with SIGABRT when a build worker thread exits
|
||||
// OPT-IN ONLY — set OMNIROUTE_BETTER_SQLITE3_STUB=1 to alias it in, and only on
|
||||
// a build host that actually hits the SIGABRT worker teardown: the native
|
||||
// Statement destructor aborts when a Next.js build worker thread exits
|
||||
// (assertion in node::RemoveEnvironmentCleanupHook, env == nullptr), which can
|
||||
// leave the build with no standalone output. At runtime the real package is
|
||||
// used (it is listed in serverExternalPackages, so it is require()'d natively,
|
||||
// not bundled); this stub only stands in during the build, where the DB is
|
||||
// never actually queried.
|
||||
// leave the build with no standalone output.
|
||||
//
|
||||
// It is NOT a build-only stand-in. A Turbopack resolveAlias rewrites the
|
||||
// request before the externals check, so aliasing `better-sqlite3` here also
|
||||
// removes it from serverExternalPackages' reach and bakes THIS FILE into the
|
||||
// shipped bundle. An artifact built with the flag on cannot open a database:
|
||||
// the sync driver chain fails with "r(...) is not a constructor", falls through
|
||||
// node:sqlite and sql.js, and the instrumentation hook aborts at boot, so every
|
||||
// route answers HTTP 500. That is exactly what an unconditional alias shipped
|
||||
// in #11343. See scripts/build/better-sqlite3-stub-flag.mjs.
|
||||
class Database {
|
||||
constructor() {}
|
||||
prepare() {
|
||||
|
||||
59
tests/unit/better-sqlite3-stub-alias-11343.test.mjs
Normal file
59
tests/unit/better-sqlite3-stub-alias-11343.test.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
// Regression test for #11343 — an unconditional Turbopack `resolveAlias` for
|
||||
// better-sqlite3 shipped the build-time stub into the runtime bundle, so every
|
||||
// artifact built from the release tip answered HTTP 500 on every route (the
|
||||
// stub export is not a constructor, the sync driver chain fell through to
|
||||
// node:sqlite and sql.js, and the instrumentation hook aborted at boot).
|
||||
//
|
||||
// The alias defeats `serverExternalPackages` because resolveAlias rewrites the
|
||||
// request BEFORE the externals check runs. It must therefore be opt-in, and a
|
||||
// default production build must externalize the REAL native package.
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const { shouldStubBetterSqlite3, betterSqlite3AliasFor } =
|
||||
await import("../../scripts/build/better-sqlite3-stub-flag.mjs");
|
||||
|
||||
describe("better-sqlite3 stub alias (#11343)", () => {
|
||||
it("default env does NOT stub better-sqlite3 (shipped artifacts get the real addon)", () => {
|
||||
assert.equal(shouldStubBetterSqlite3({}), false);
|
||||
assert.deepEqual(betterSqlite3AliasFor({}), {});
|
||||
});
|
||||
|
||||
it("only the exact opt-in value enables the stub", () => {
|
||||
for (const value of ["", "0", "true", "yes"]) {
|
||||
assert.equal(
|
||||
shouldStubBetterSqlite3({ OMNIROUTE_BETTER_SQLITE3_STUB: value }),
|
||||
false,
|
||||
`OMNIROUTE_BETTER_SQLITE3_STUB=${JSON.stringify(value)} must not enable the stub`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("OMNIROUTE_BETTER_SQLITE3_STUB=1 opts into the stub (SIGABRT-prone build hosts, #10060)", () => {
|
||||
assert.equal(shouldStubBetterSqlite3({ OMNIROUTE_BETTER_SQLITE3_STUB: "1" }), true);
|
||||
assert.deepEqual(betterSqlite3AliasFor({ OMNIROUTE_BETTER_SQLITE3_STUB: "1" }), {
|
||||
"better-sqlite3": "./src/lib/db/better-sqlite3.stub.js",
|
||||
});
|
||||
});
|
||||
|
||||
it("next.config.mjs derives the turbopack alias from the flag (no unconditional stub)", () => {
|
||||
const config = readFileSync(new URL("../../next.config.mjs", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
config,
|
||||
/betterSqlite3AliasFor/,
|
||||
"next.config.mjs must use betterSqlite3AliasFor()"
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
config,
|
||||
/^\s*"better-sqlite3":\s*"\.\/src\/lib\/db\/better-sqlite3\.stub\.js",?\s*$/m,
|
||||
"next.config.mjs must not hardcode the better-sqlite3 stub alias"
|
||||
);
|
||||
});
|
||||
|
||||
it("better-sqlite3 stays in serverExternalPackages so the default build externalizes it", () => {
|
||||
const config = readFileSync(new URL("../../next.config.mjs", import.meta.url), "utf8");
|
||||
const externals = config.slice(config.indexOf("serverExternalPackages:"));
|
||||
assert.match(externals.slice(0, externals.indexOf("]")), /"better-sqlite3"/);
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,10 @@ test("next config declares Turbopack aliases, runtime assets and server external
|
||||
// A default production build must NOT alias it, or the stub ships to npm/Electron/VPS
|
||||
// artifacts and breaks Agent Bridge start. See the dedicated env-matrix test below.
|
||||
assert.equal(nextConfig.turbopack.resolveAlias["@/mitm/manager"], undefined);
|
||||
// #11343: same story for the better-sqlite3 build stub. resolveAlias is applied
|
||||
// BEFORE the serverExternalPackages check, so an unconditional alias bundles the
|
||||
// stub and every route answers 500 at runtime ("r(...) is not a constructor").
|
||||
assert.equal(nextConfig.turbopack.resolveAlias["better-sqlite3"], undefined);
|
||||
assert.equal(nextConfig.outputFileTracingRoot, process.cwd());
|
||||
assert.ok(tracingIncludes.includes("./src/lib/db/migrations/**/*"));
|
||||
assert.ok(
|
||||
@@ -118,6 +122,28 @@ test("next config declares Turbopack aliases, runtime assets and server external
|
||||
}
|
||||
});
|
||||
|
||||
test("Turbopack aliases better-sqlite3 to the stub ONLY when OMNIROUTE_BETTER_SQLITE3_STUB=1 (#11343)", async () => {
|
||||
const original = process.env.OMNIROUTE_BETTER_SQLITE3_STUB;
|
||||
try {
|
||||
delete process.env.OMNIROUTE_BETTER_SQLITE3_STUB;
|
||||
const { default: def } = await loadNextConfig("bettersqlite-default");
|
||||
assert.equal(def.turbopack.resolveAlias["better-sqlite3"], undefined);
|
||||
// The default build must keep the real package reachable as an external, which
|
||||
// is exactly what the alias silently defeated.
|
||||
assert.ok(new Set(def.serverExternalPackages).has("better-sqlite3"));
|
||||
|
||||
process.env.OMNIROUTE_BETTER_SQLITE3_STUB = "1";
|
||||
const { default: stubbed } = await loadNextConfig("bettersqlite-optin");
|
||||
assert.equal(
|
||||
stubbed.turbopack.resolveAlias["better-sqlite3"],
|
||||
"./src/lib/db/better-sqlite3.stub.js"
|
||||
);
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.OMNIROUTE_BETTER_SQLITE3_STUB;
|
||||
else process.env.OMNIROUTE_BETTER_SQLITE3_STUB = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("Turbopack aliases @/mitm/manager to the stub ONLY when OMNIROUTE_MITM_STUB=1 (#6344)", async () => {
|
||||
const original = process.env.OMNIROUTE_MITM_STUB;
|
||||
try {
|
||||
|
||||
@@ -49,76 +49,6 @@ test("GET /api/openapi/spec documents its conditional management auth contract",
|
||||
);
|
||||
});
|
||||
|
||||
test("POST /api/openapi/try documents its bounded management proxy contract", () => {
|
||||
const operation = paths["/api/openapi/try"]?.post;
|
||||
|
||||
assert.ok(operation, "POST /api/openapi/try must be present in docs/openapi.yaml");
|
||||
assert.deepEqual(operation.security, [{ BearerAuth: [] }, { ManagementSessionAuth: [] }]);
|
||||
assert.match(operation.description ?? "", /same-origin/);
|
||||
assert.match(operation.description ?? "", /When `requireLogin` is disabled/);
|
||||
|
||||
const requestBody = operation.requestBody;
|
||||
const requestSchema = requestBody?.content?.["application/json"]?.schema;
|
||||
assert.equal(requestBody?.required, true);
|
||||
assert.equal(requestSchema?.type, "object");
|
||||
assert.deepEqual(requestSchema?.required, ["path"]);
|
||||
assert.deepEqual(requestSchema?.properties?.method?.enum, [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"HEAD",
|
||||
"OPTIONS",
|
||||
]);
|
||||
assert.equal(requestSchema?.properties?.method?.default, "GET");
|
||||
assert.equal(requestSchema?.properties?.path?.minLength, 1);
|
||||
assert.equal(
|
||||
requestSchema?.properties?.path?.pattern,
|
||||
"^/(?:api/|v1/|v1beta/|a2a|\\.well-known/agent\\.json)"
|
||||
);
|
||||
assert.equal(requestSchema?.properties?.headers?.type, "object");
|
||||
assert.deepEqual(requestSchema?.properties?.headers?.additionalProperties, {
|
||||
type: "string",
|
||||
});
|
||||
assert.deepEqual(requestSchema?.properties?.headers?.default, {});
|
||||
assert.ok("body" in requestSchema.properties);
|
||||
|
||||
const successSchema = operation.responses?.["200"]?.content?.["application/json"]?.schema;
|
||||
assert.equal(successSchema?.type, "object");
|
||||
assert.equal(successSchema?.additionalProperties, false);
|
||||
assert.deepEqual(successSchema?.required, [
|
||||
"status",
|
||||
"statusText",
|
||||
"headers",
|
||||
"body",
|
||||
"latencyMs",
|
||||
"contentType",
|
||||
]);
|
||||
assert.equal(successSchema?.properties?.status?.type, "integer");
|
||||
assert.equal(successSchema?.properties?.status?.minimum, 0);
|
||||
assert.equal(successSchema?.properties?.statusText?.type, "string");
|
||||
assert.equal(successSchema?.properties?.headers?.type, "object");
|
||||
assert.deepEqual(successSchema?.properties?.headers?.additionalProperties, {
|
||||
type: "string",
|
||||
});
|
||||
assert.match(successSchema?.properties?.body?.description ?? "", /10,000 characters/);
|
||||
assert.equal(successSchema?.properties?.latencyMs?.type, "integer");
|
||||
assert.equal(successSchema?.properties?.latencyMs?.minimum, 0);
|
||||
assert.equal(successSchema?.properties?.contentType?.type, "string");
|
||||
|
||||
const badRequestSchema = operation.responses?.["400"]?.content?.["application/json"]?.schema;
|
||||
assert.equal(badRequestSchema?.oneOf?.length, 2);
|
||||
assert.equal(badRequestSchema?.oneOf?.[0]?.$ref, "#/components/schemas/ValidationErrorResponse");
|
||||
assert.equal(badRequestSchema?.oneOf?.[1]?.properties?.error?.type, "string");
|
||||
assert.equal(
|
||||
operation.responses?.["401"]?.$ref,
|
||||
"#/components/responses/ManagementAuthenticationRequired"
|
||||
);
|
||||
assert.equal(operation.responses?.["403"]?.$ref, "#/components/responses/ManagementInvalidToken");
|
||||
assert.equal(operation.responses?.["503"]?.$ref, "#/components/responses/InternalError");
|
||||
});
|
||||
|
||||
test("every x-always-protected path matches ALWAYS_PROTECTED_API_PATHS in routeGuard.ts", () => {
|
||||
for (const [pathStr, methods] of Object.entries(paths)) {
|
||||
if (!methods || typeof methods !== "object") continue;
|
||||
|
||||
Reference in New Issue
Block a user