Compare commits

..

1 Commits

Author SHA1 Message Date
Xiangzhe
30cac12f5e fix(build): stop bundling the better-sqlite3 stub at runtime (#11343)
next.config.mjs aliased `better-sqlite3` to its build-time stub
unconditionally, recording the premise that "runtime still uses the real
package via serverExternalPackages". That premise does not hold: a
Turbopack resolveAlias rewrites the request BEFORE the externals check
runs, so the request stopped matching the serverExternalPackages entry
and the stub was baked into the shipped bundle.

Every artifact built from the release tip then answered HTTP 500 on
every route -- the sync driver failed with "r(...) is not a constructor"
(the minified stub export), fell through node:sqlite and sql.js, and the
instrumentation hook aborted at boot.

Same failure shape as #6344, one alias above it in the same object, so
it gets the same treatment: a shared flag helper makes the alias opt-in
via OMNIROUTE_BETTER_SQLITE3_STUB=1, and a default build externalizes
the real native addon. Nobody sets the flag today; it exists for a build
host that genuinely hits the SIGABRT worker teardown from #10060, and
such a build is not shippable -- which the helper and the stub header
now say explicitly instead of describing the stub as a harmless
build-only stand-in.

Closes #11343
2026-08-24 09:56:20 -03:00
9 changed files with 144 additions and 180 deletions

View File

@@ -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)

View File

@@ -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))

View File

@@ -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:

View File

@@ -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

View 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" }
: {};
}

View File

@@ -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() {

View 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"/);
});
});

View File

@@ -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 {

View File

@@ -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;