Compare commits

...

5 Commits

Author SHA1 Message Date
dependabot[bot]
33defcf069 deps: bump the development group across 1 directory with 22 updates
Bumps the development group with 22 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.61.1` | `1.62.1` |
| [@size-limit/file](https://github.com/ai/size-limit) | `12.1.0` | `13.0.3` |
| [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) | `6.9.1` | `7.0.0` |
| [@types/better-sqlite3](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/better-sqlite3) | `7.6.13` | `9.6.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.1.1` | `26.2.0` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.17` | `19.2.18` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.3` | `19.2.4` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.3` | `6.0.5` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `10.0.3` | `10.0.4` |
| [dpdm](https://github.com/acrazing/dpdm) | `4.2.0` | `4.3.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.2.10` | `16.3.0` |
| [fumadocs-mdx](https://github.com/fuma-nama/fumadocs) | `15.2.0` | `15.2.2` |
| [jsdom](https://github.com/jsdom/jsdom) | `29.1.1` | `30.0.1` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.27.0` | `6.32.0` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.1.0` | `17.3.0` |
| [opencode-ai](https://github.com/anomalyco/opencode) | `1.18.8` | `1.18.15` |
| [prettier](https://github.com/prettier/prettier) | `3.9.5` | `3.9.6` |
| [promptfoo](https://github.com/promptfoo/promptfoo) | `0.121.19` | `0.122.0` |
| [size-limit](https://github.com/ai/size-limit) | `12.1.0` | `13.0.3` |
| [type-coverage](https://github.com/plantain-00/type-coverage) | `2.29.7` | `2.30.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.65.0` | `8.66.0` |
| [wait-on](https://github.com/jeffbski/wait-on) | `9.0.10` | `9.1.0` |



Updates `@playwright/test` from 1.61.1 to 1.62.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.61.1...v1.62.1)

Updates `@size-limit/file` from 12.1.0 to 13.0.3
- [Release notes](https://github.com/ai/size-limit/releases)
- [Changelog](https://github.com/ai/size-limit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/size-limit/compare/12.1.0...13.0.3)

Updates `@testing-library/jest-dom` from 6.9.1 to 7.0.0
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.0)

Updates `@types/better-sqlite3` from 7.6.13 to 9.6.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/better-sqlite3)

Updates `@types/node` from 26.1.1 to 26.2.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/react` from 19.2.17 to 19.2.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 19.2.3 to 19.2.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `@vitejs/plugin-react` from 6.0.3 to 6.0.5
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.5/packages/plugin-react)

Updates `concurrently` from 10.0.3 to 10.0.4
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v10.0.3...v10.0.4)

Updates `dpdm` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/acrazing/dpdm/releases)
- [Commits](https://github.com/acrazing/dpdm/compare/v4.2.0...v4.3.0)

Updates `eslint-config-next` from 16.2.10 to 16.3.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.0/packages/eslint-config-next)

Updates `fumadocs-mdx` from 15.2.0 to 15.2.2
- [Release notes](https://github.com/fuma-nama/fumadocs/releases)
- [Commits](https://github.com/fuma-nama/fumadocs/compare/fumadocs-mdx@15.2.0...fumadocs-mdx@15.2.2)

Updates `jsdom` from 29.1.1 to 30.0.1
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.1.1...v30.0.1)

Updates `knip` from 6.27.0 to 6.32.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.32.0/packages/knip)

Updates `lint-staged` from 17.1.0 to 17.3.0
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.1.0...v17.3.0)

Updates `opencode-ai` from 1.18.8 to 1.18.15
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/v1.18.8...v1.18.15)

Updates `prettier` from 3.9.5 to 3.9.6
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.5...3.9.6)

Updates `promptfoo` from 0.121.19 to 0.122.0
- [Release notes](https://github.com/promptfoo/promptfoo/releases)
- [Changelog](https://github.com/promptfoo/promptfoo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/promptfoo/promptfoo/compare/0.121.19...0.122.0)

Updates `size-limit` from 12.1.0 to 13.0.3
- [Release notes](https://github.com/ai/size-limit/releases)
- [Changelog](https://github.com/ai/size-limit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/size-limit/compare/12.1.0...13.0.3)

Updates `type-coverage` from 2.29.7 to 2.30.1
- [Changelog](https://github.com/plantain-00/type-coverage/blob/master/CHANGELOG.md)
- [Commits](https://github.com/plantain-00/type-coverage/compare/v2.29.7...v2.30.1)

Updates `typescript-eslint` from 8.65.0 to 8.66.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/typescript-eslint)

Updates `wait-on` from 9.0.10 to 9.1.0
- [Release notes](https://github.com/jeffbski/wait-on/releases)
- [Commits](https://github.com/jeffbski/wait-on/compare/v9.0.10...v9.1.0)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.62.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: "@size-limit/file"
  dependency-version: 13.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 7.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: "@types/better-sqlite3"
  dependency-version: 9.6.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: concurrently
  dependency-version: 10.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: dpdm
  dependency-version: 4.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: eslint-config-next
  dependency-version: 16.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: fumadocs-mdx
  dependency-version: 15.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: jsdom
  dependency-version: 30.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: knip
  dependency-version: 6.32.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: lint-staged
  dependency-version: 17.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: opencode-ai
  dependency-version: 1.18.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: prettier
  dependency-version: 3.9.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: promptfoo
  dependency-version: 0.122.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: size-limit
  dependency-version: 13.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: type-coverage
  dependency-version: 2.30.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: typescript-eslint
  dependency-version: 8.66.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: wait-on
  dependency-version: 9.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-10 18:32:04 +00:00
Diego Rodrigues de Sa e Souza
8fc4023f94 fix(migrations): allow fresh install past mass-migration guard (#9934) (#10022)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-10 11:18:19 -03:00
Diego Rodrigues de Sa e Souza
aafdc4d4c6 fix(images): normalize image endpoint error format (#9981) (#10020)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-10 11:18:14 -03:00
Diego Rodrigues de Sa e Souza
fed0858f89 fix(encryption): identify failing credential in decrypt errors (#9927) (#10019)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-10 11:18:09 -03:00
Diego Rodrigues de Sa e Souza
2b6977229b fix(search): nest Exa contents options for /search (#9914) (#10018)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-10 11:18:04 -03:00
17 changed files with 1725 additions and 866 deletions

View File

@@ -0,0 +1 @@
- fix(search): nest Exa contents options (text/highlights) for /search API (#9914)

View File

@@ -0,0 +1 @@
- fix(encryption): name failing credential + recovery path in decrypt errors, dedupe per connection (#9927)

View File

@@ -0,0 +1 @@
- fix(migrations): don't abort on fresh install with only the 001 seed (#9934)

View File

@@ -0,0 +1 @@
- fix(images): normalize terminal upstream errors via OpenAI-standard type/code (#9981)

View File

@@ -336,8 +336,10 @@ function buildExaRequest(
query: params.query,
numResults: params.maxResults,
type: "auto",
text: true,
highlights: true,
contents: {
text: true,
highlights: true,
},
};
if (includes.length) body.includeDomains = includes;
if (excludes.length) body.excludeDomains = excludes;

2055
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -342,54 +342,54 @@
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@cyclonedx/cyclonedx-npm": "6.0.0",
"@playwright/test": "^1.60.0",
"@size-limit/file": "^12.1.0",
"@playwright/test": "^1.62.1",
"@size-limit/file": "^13.0.3",
"@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/tap-runner": "^9.6.1",
"@tailwindcss/postcss": "^4.3.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@types/better-sqlite3": "^7.6.13",
"@types/better-sqlite3": "^9.6.0",
"@types/bun": "latest",
"@types/node": "^26.1.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/safe-regex": "^1.1.6",
"@types/ws": "^8.18.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-react": "^6.0.5",
"bun": "1.3.14",
"c8": "^12.0.0",
"concurrently": "^10.0.3",
"concurrently": "^10.0.4",
"cross-env": "^10.1.0",
"ctrf": "^0.2.1",
"dpdm": "^4.2.0",
"dpdm": "^4.3.0",
"eslint": "^9.39.4",
"eslint-config-next": "16.2.10",
"eslint-config-next": "16.3.0",
"eslint-plugin-sonarjs": "^4.1.0",
"fast-check": "^4.8.0",
"fumadocs-mdx": "^15.0.7",
"fumadocs-mdx": "^15.2.2",
"glob": "^13.0.6",
"httpyac": "^6.16.7",
"husky": "^9.1.7",
"jscpd": "^4.2.5",
"jsdom": "^29.1.1",
"jsdom": "^30.0.1",
"junit-to-ctrf": "^0.0.14",
"knip": "^6.18.0",
"knip": "^6.32.0",
"license-checker-rseidelsohn": "^5.0.1",
"lint-staged": "^17.0.8",
"lint-staged": "^17.3.0",
"lockfile-lint": "^5.0.0",
"node-loader": "^2.1.0",
"opencode-ai": "1.18.8",
"opencode-ai": "1.18.15",
"playwright-ctrf-json-reporter": "^0.0.29",
"prettier": "^3.8.3",
"promptfoo": "^0.121.18",
"size-limit": "^12.1.0",
"prettier": "^3.9.6",
"promptfoo": "^0.122.0",
"size-limit": "^13.0.3",
"tailwindcss": "^4.3.0",
"type-coverage": "^2.29.7",
"type-coverage": "^2.30.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"typescript-eslint": "^8.66.0",
"vitest": "^4.1.7",
"wait-on": "^9.0.10",
"wait-on": "^9.1.0",
"wtfnode": "^0.10.1"
},
"lint-staged": {

View File

@@ -14,17 +14,35 @@ import { buildErrorBody } from "@omniroute/open-sse/utils/error";
* Returns a 424 (Failed Dependency) response with a clear, sanitized message
* when the connection carries that flag; otherwise null (proceed normally).
*/
const STALE_ENCRYPTION_MESSAGE =
"Stored API key cannot be decrypted (STORAGE_ENCRYPTION_KEY changed or unset). Re-enter the API key.";
export function buildStaleEncryptionKeyResponse(
connection: { credentialDecryptFailed?: unknown } | null | undefined
connection:
| {
credentialDecryptFailed?: unknown;
id?: unknown;
provider?: unknown;
}
| null
| undefined
): NextResponse | null {
if (!connection || connection.credentialDecryptFailed !== true) return null;
// #9927 — surface WHICH credential failed plus the recovery path so the
// dashboard points the operator at the account to re-authenticate instead of
// a generic "API key cannot be decrypted".
const provider = typeof connection.provider === "string" ? connection.provider : "";
const id = typeof connection.id === "string" ? connection.id : "";
const identity = [provider && `provider "${provider}"`, id && `connection ${id}`]
.filter(Boolean)
.join(", ");
const message =
`Stored credential${identity ? ` for ${identity}` : ""} cannot be decrypted ` +
`(STORAGE_ENCRYPTION_KEY changed or unset). Re-authenticate this account, or verify ` +
`STORAGE_ENCRYPTION_KEY matches the key used to store it.`;
// buildErrorBody sanitizes the message (Rule #12); override the type so the
// client can key off the specific stale-encryption cause.
const body = buildErrorBody(424, STALE_ENCRYPTION_MESSAGE);
const body = buildErrorBody(424, message);
body.error.type = "storage_encryption_stale";
return NextResponse.json(body, { status: 424 });
}

View File

@@ -308,10 +308,11 @@ async function postHandler(request, context) {
}
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
return new Response(JSON.stringify(errorPayload), {
status: (result as any).status,
headers: { "Content-Type": "application/json" },
});
const message =
typeof errorPayload?.error?.message === "string"
? errorPayload.error.message
: "Image generation provider error";
return errorResponse((result as any).status, message);
}
export const POST = withInjectionGuard(postHandler);

View File

@@ -119,8 +119,9 @@ export async function POST(request, { params }) {
}
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
return new Response(JSON.stringify(errorPayload), {
status: (result as any).status,
headers: { "Content-Type": "application/json" },
});
const message =
typeof errorPayload?.error?.message === "string"
? errorPayload.error.message
: "Image generation provider error";
return errorResponse((result as any).status, message);
}

View File

@@ -1045,13 +1045,35 @@ export function getDbInstance(): SqliteDatabase {
// This is needed so the migration runner skips the mass-migration safety abort
// that would otherwise trigger because heuristic seeding marks some migrations
// as applied, making the fresh DB look like a wiped existing DB (#1328).
const isNewDb = !fs.existsSync(sqliteFile);
// #9934: also classify as fresh a file that `omniroute setup` created with
// only the clipped skeleton schema (see the probe below) — even though the
// file exists, it has never had migrations run.
let isNewDb = !fs.existsSync(sqliteFile);
// Detect and handle old schema format — preserve data when possible (#146)
// Uses a single probe connection that becomes the real connection when possible.
if (fs.existsSync(sqliteFile)) {
try {
const probe = openSqliteDatabase(sqliteFile, { readonly: true });
// #9934: init asymmetry — bin/cli/sqlite.mjs::openOmniRouteDb (used by
// `omniroute setup`) creates storage.sqlite with only the partial inline
// schema (key_value + provider_connections) and never runs migrations.
// Purely file-existence-based freshness made that file look like an
// existing DB, so the first `serve` auto-seeded only the 001 marker and
// tripped the mass-migration safety abort on a brand-new install. A
// skeleton file has provider_connections but none of the tables the 001
// migration creates (combos) — treat it as fresh, not as a wiped DB.
const probeHasProviderConnections = !!probe
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='provider_connections'"
)
.get();
const probeHasCombos = !!probe
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='combos'")
.get();
if (probeHasProviderConnections && !probeHasCombos) {
isNewDb = true;
}
const hasOldSchema = probe
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'")
.get();

View File

@@ -51,6 +51,31 @@ export interface ConnectionFields {
[key: string]: unknown;
}
/**
* #9927 — dedupe tracker for credential-decrypt-failure messages. The health
* sweep / refresh / request routing re-decrypt the same corrupt row every
* cycle; we log the enriched, actionable message ONCE per
* (provider + connection + failing-ciphertext) state so it does not spam
* every sweep, while still re-logging if the row state actually changes
* (e.g. a different field starts failing) instead of permanently suppressing.
*/
const loggedDecryptFailures = new Set<string>();
function decryptFailureSignature(
connectionId: string,
provider: string,
failed: Array<{ field: string; value: unknown }>
): string {
const parts = failed
.map((f) => `${f.field}:${typeof f.value === "string" ? f.value : ""}`)
.sort()
.join("|");
return `${provider}::${connectionId}::${parts}`;
}
const RECOVERY_HINT =
"Re-authenticate this account, or verify STORAGE_ENCRYPTION_KEY matches the key used to store it.";
/**
* Derive the PRIMARY encryption key using the static salt.
* This is the canonical key derivation that all new encryptions use.
@@ -157,7 +182,10 @@ export function encrypt(plaintext: string | null | undefined): string | null | u
* auto-migration: the next encrypt() call will re-encrypt it with the
* static-salt key, gradually migrating the database.
*/
export function decrypt(ciphertext: string | null | undefined): string | null | undefined {
export function decrypt(
ciphertext: string | null | undefined,
opts?: { quiet?: boolean }
): string | null | undefined {
if (!ciphertext || typeof ciphertext !== "string") return ciphertext;
// Not encrypted — return as-is (legacy plaintext or passthrough mode)
@@ -204,14 +232,21 @@ export function decrypt(ciphertext: string | null | undefined): string | null |
return decrypted;
}
console.error(
`[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` +
`Auth tag validation likely failed.`
);
// #9927 — the low-level generic log is suppressed when called through the
// connection-decryption path (quiet:true); decryptConnectionFields emits a
// single enriched message naming the credential + recovery path instead.
if (!opts?.quiet) {
console.error(
`[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` +
`Auth tag validation likely failed.`
);
}
return null;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("[Encryption] Decryption failed:", message);
if (!opts?.quiet) {
console.error("[Encryption] Decryption failed:", message);
}
return null;
}
}
@@ -242,10 +277,13 @@ export function decryptConnectionFields<T extends ConnectionFields | null | unde
if (!row) return row;
if (!isEncryptionEnabled()) return row;
const apiKey = decrypt(row.apiKey);
const accessToken = decrypt(row.accessToken);
const refreshToken = decrypt(row.refreshToken);
const idToken = decrypt(row.idToken);
// quiet:true — the low-level generic decrypt() log is suppressed here so a
// single failure emits ONE enriched message (below) naming the credential
// and recovery path (#9927) instead of one generic line per field per cycle.
const apiKey = decrypt(row.apiKey, { quiet: true });
const accessToken = decrypt(row.accessToken, { quiet: true });
const refreshToken = decrypt(row.refreshToken, { quiet: true });
const idToken = decrypt(row.idToken, { quiet: true });
// #6148 — a stored credential that is still encrypted (`enc:v1:…`) but
// decrypts to null means the STORAGE_ENCRYPTION_KEY changed or was unset.
@@ -257,6 +295,31 @@ export function decryptConnectionFields<T extends ConnectionFields | null | unde
(looksEncrypted(row.refreshToken) && refreshToken === null) ||
(looksEncrypted(row.idToken) && idToken === null);
if (credentialDecryptFailed) {
const failed: Array<{ field: string; value: unknown }> = [];
if (looksEncrypted(row.apiKey) && apiKey === null) failed.push({ field: "apiKey", value: row.apiKey });
if (looksEncrypted(row.accessToken) && accessToken === null)
failed.push({ field: "accessToken", value: row.accessToken });
if (looksEncrypted(row.refreshToken) && refreshToken === null)
failed.push({ field: "refreshToken", value: row.refreshToken });
if (looksEncrypted(row.idToken) && idToken === null) failed.push({ field: "idToken", value: row.idToken });
const connectionId = typeof row.id === "string" ? row.id : "";
const provider = typeof row.provider === "string" ? row.provider : "unknown";
const fields = failed.map((f) => f.field).join(", ");
// Dedupe per credential/row state: the sweep re-decrypts the same corrupt
// row every cycle — log ONCE unless the failing state actually changes.
const signature = decryptFailureSignature(connectionId, provider, failed);
if (!loggedDecryptFailures.has(signature)) {
loggedDecryptFailures.add(signature);
console.error(
`[Encryption] Failed to decrypt credential(s) [${fields}] for provider ` +
`"${provider}" (connection ${connectionId || "unknown"}). ${RECOVERY_HINT}`
);
}
}
return {
...row,
apiKey,

View File

@@ -922,9 +922,26 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
// interpolates this resolved value, so it auto-reflects any override.
const maxPendingMigrations = resolveMaxPendingMigrations();
// #9934: `omniroute setup`'s openOmniRouteDb writes a partial skeleton file
// (provider_connections + key_value) that has never had migrations run. When
// the first `serve` opens it and auto-seeds only the 001 marker, the applied
// set is exactly {001} — which would otherwise look like a wiped existing DB
// and trip this abort on a brand-new install. This is distinct from a real
// wiped/backup-restored database: that case has a non-trivial physical schema
// (baseline inference is non-null) and full data tables, so it still aborts.
// The 001-marker-only state on a provider_connections skeleton is the fresh
// auto-seed — let it through. A genuinely empty table is already exempt via
// `applied.size > 0`, and an upgraded DB has a non-trivial applied set.
const isFreshSeedOnly =
applied.size === 1 &&
applied.has("001") &&
inferPhysicalSchemaBaseline(db) === null &&
hasTable(db, "provider_connections");
if (
!isTestEnvironment &&
!isNewDb &&
!isFreshSeedOnly &&
process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true" &&
maxPendingMigrations > 0 &&
applied.size > 0 &&

View File

@@ -0,0 +1,138 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import Database from "better-sqlite3";
import { resetDbInstance } from "../../src/lib/db/core.ts";
// Regression guard for #9934 — init asymmetry breaks a fresh install.
//
// `omniroute setup` (bin/cli/sqlite.mjs::openOmniRouteDb) creates
// storage.sqlite with the *partial* inline schema (key_value +
// provider_connections) but NEVER creates _omniroute_migrations and never runs
// migrations. That file flips the server's new-DB heuristic
// (src/lib/db/core.ts uses `!fs.existsSync(sqliteFile)`), so the first
// `omniroute serve` believes it is an existing DB, auto-seeds only the 001
// marker, and then trips the mass-migration safety abort because 139 pending
// migrations exceed the default threshold of 50 (#6260 gate).
//
// A DB whose ONLY applied migration is the 001 initial-schema auto-seed is a
// fresh install, not a wiped/backup-restored database — it must NOT abort.
const serial = { concurrency: false };
// Re-import a module so module-level env-derived constants (DATA_DIR,
// SQLITE_FILE) re-resolve after we set DATA_DIR. Static import cannot work
// here: the whole point is exercising the module-loading boundary.
async function importFresh(modulePath: string) {
const url = pathToFileURL(path.resolve(modulePath)).href;
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
}
// Simulate a production (non-test) process so the #6260 mass-migration safety
// gate is actually LIVE: under `node --test` the runner would be detected and
// the gate skipped, making the bug invisible.
function withNonTestEnvironment<R>(fn: () => R): R {
const originalNodeEnv = process.env.NODE_ENV;
const originalVitest = process.env.VITEST;
const originalDisableAutoBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
const originalArgv = [...process.argv];
const originalExecArgv = [...process.execArgv];
delete process.env.NODE_ENV;
delete process.env.VITEST;
delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
process.argv = process.argv.filter((arg) => !arg.includes("test"));
process.execArgv = process.execArgv.filter((arg) => !arg.includes("test"));
try {
return fn();
} finally {
process.argv = originalArgv;
process.execArgv = originalExecArgv;
if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = originalNodeEnv;
if (originalVitest === undefined) delete process.env.VITEST;
else process.env.VITEST = originalVitest;
if (originalDisableAutoBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
else process.env.DISABLE_SQLITE_AUTO_BACKUP = originalDisableAutoBackup;
}
}
function cleanupGlobalDb() {
try {
const g = globalThis as Record<string, { open?: boolean; close?: () => void }>;
if (g.__omnirouteDb?.open) g.__omnirouteDb.close?.();
} catch {
/* ignore */
}
delete (globalThis as Record<string, unknown>).__omnirouteDb;
}
test.after(() => {
cleanupGlobalDb();
resetDbInstance();
});
test(
"fresh `omniroute setup` DB (only the 001 seed) survives first serve without mass-migration abort (#9934)",
serial,
async () => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9934-"));
const originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dataDir;
try {
// Step 1 — mimic `omniroute setup`: the CLI opens the DB, writes the
// partial inline schema (key_value + provider_connections) and closes it,
// WITHOUT running migrations or creating _omniroute_migrations.
const cli = await importFresh("bin/cli/sqlite.mjs");
const setup = await cli.openOmniRouteDb();
assert.ok(fs.existsSync(setup.dbPath), "setup created storage.sqlite");
setup.db.close();
const onDisk = new Database(setup.dbPath, { readonly: true });
try {
const hasMigrationTable = !!onDisk
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
.get("_omniroute_migrations");
assert.equal(
hasMigrationTable,
false,
"setup must NOT pre-create the migrations tracking table (bug premise)"
);
} finally {
onDisk.close();
}
// Step 2 — mimic the first `omniroute serve`: the real server opens the
// same DB, auto-seeds only the 001 marker and runs migrations. Under a
// live (non-test) safety gate this must NOT throw.
const core = await importFresh("src/lib/db/core.ts");
cleanupGlobalDb();
resetDbInstance();
let db: { prepare?: (sql: string) => { get: () => { maxV: number } | undefined } };
assert.doesNotThrow(() => {
withNonTestEnvironment(() => {
db = core.getDbInstance();
});
}, "first serve must not abort on a fresh setup DB that only has the 001 seed (#9934)");
// Prove the fresh DB actually got migrated past 001 to the latest version.
const maxRow = db.prepare(
"SELECT MAX(CAST(version AS INTEGER)) AS maxV FROM _omniroute_migrations"
).get();
assert.ok(
(maxRow?.maxV ?? 0) > 1,
`expected migrations beyond 001 to run, got max=${maxRow?.maxV}`
);
} finally {
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
fs.rmSync(dataDir, { recursive: true, force: true });
}
}
);

View File

@@ -0,0 +1,108 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { pathToFileURL } from "node:url";
// #9927 — A credential that no longer decrypts (e.g. STORAGE_ENCRYPTION_KEY
// changed between restarts) must emit a single, enriched error naming the
// provider + connection id + failing field(s) and a recovery path, instead of
// the generic low-level `[Encryption] Decryption failed … Auth tag validation
// likely failed` line that carries no identity and is re-printed every sweep.
const ORIGINAL_STORAGE_KEY = process.env.STORAGE_ENCRYPTION_KEY;
// Cache-busted fresh import so the encryption module re-derives its key from
// the current STORAGE_ENCRYPTION_KEY and resets module-level dedupe state.
async function importFresh(modulePath: string) {
const url = pathToFileURL(path.resolve(modulePath)).href;
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
}
test.after(() => {
if (ORIGINAL_STORAGE_KEY === undefined) {
delete process.env.STORAGE_ENCRYPTION_KEY;
} else {
process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_KEY;
}
});
function captureConsoleError(fn: () => void): string[] {
const original = console.error;
const logs: string[] = [];
console.error = (...args: unknown[]) => {
logs.push(args.join(" "));
};
try {
fn();
} finally {
console.error = original;
}
return logs;
}
test("decryptConnectionFields logs failed credential identity + recovery path (#9927)", async () => {
// 1. Encrypt an apiKey under key A.
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-A";
const encA = await importFresh("src/lib/db/encryption.ts");
const ciphertext = encA.encrypt("sk-real-secret-key");
assert.match(ciphertext, /^enc:v1:/, "expected a real enc:v1 ciphertext");
// 2. Read it back under a DIFFERENT key B (simulating a changed key).
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-B";
const encB = await importFresh("src/lib/db/encryption.ts");
const logs = captureConsoleError(() => {
encB.decryptConnectionFields({
id: "conn-9927",
provider: "openai",
apiKey: ciphertext,
});
});
// Must flag the failure so callers can surface the cause.
const decrypted = encB.decryptConnectionFields({
id: "conn-9927",
provider: "openai",
apiKey: ciphertext,
});
assert.equal(decrypted.credentialDecryptFailed, true);
// The generic low-level log must NOT fire (quiet:true); instead ONE enriched
// message names provider + connection id + recovery path.
assert.equal(
logs.some((l) => /Auth tag validation likely failed/.test(l)),
false,
"generic low-level decrypt log must be suppressed on the connection path"
);
const enriched = logs.find((l) => l.includes("Failed to decrypt credential(s)"));
assert.ok(enriched, "expected an enriched credential-decrypt-failure log");
assert.match(enriched, /provider "openai"/, "log must name the provider");
assert.match(enriched, /conn-9927/, "log must name the connection id");
assert.match(enriched, /apiKey/, "log must name the failing field");
assert.match(
enriched,
/STORAGE_ENCRYPTION_KEY matches the key used to store it/,
"log must include the recovery path"
);
});
test("credential-decrypt failure is logged once per connection (dedupe #9927)", async () => {
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-dedupe-A";
const encA = await importFresh("src/lib/db/encryption.ts");
const ciphertext = encA.encrypt("sk-dedupe-key");
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-dedupe-B";
const encB = await importFresh("src/lib/db/encryption.ts");
const row = { id: "conn-dedupe", provider: "openai", apiKey: ciphertext };
const logs = captureConsoleError(() => {
// Simulate the health sweep re-decrypting the same corrupt row repeatedly.
for (let i = 0; i < 5; i++) {
encB.decryptConnectionFields(row);
}
});
const enriched = logs.filter((l) => l.includes("Failed to decrypt credential(s)"));
assert.equal(enriched.length, 1, "identical failure must be logged once per connection");
});

View File

@@ -701,6 +701,67 @@ test("provider-scoped image generation POST uses the shared 401 account fallback
]);
});
test("v1 image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => {
await seedConnection("openai", { apiKey: "single-expired-image-key" });
globalThis.fetch = async (url, options: RequestInit = {}) => {
assert.equal(String(url), "https://api.openai.com/v1/images/generations");
const authorization = new Headers(options.headers).get("authorization") ?? "";
assert.equal(authorization, "Bearer single-expired-image-key");
return new Response(JSON.stringify({ error: { message: "expired access token" } }), {
status: 401,
headers: { "content-type": "application/json" },
});
};
const response = await imageRoute.POST(
new Request("http://localhost/api/v1/images/generations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "openai/gpt-image-2", prompt: "normalize terminal 401" }),
})
);
const body = (await response.json()) as ErrorResponseBody;
assert.equal(response.status, 401);
assert.deepEqual(body.error, {
message: "expired access token",
type: "authentication_error",
code: "invalid_api_key",
});
});
test("provider-scoped image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => {
await seedConnection("openai", { apiKey: "provider-single-expired-key" });
globalThis.fetch = async (url, options: RequestInit = {}) => {
assert.equal(String(url), "https://api.openai.com/v1/images/generations");
const authorization = new Headers(options.headers).get("authorization") ?? "";
assert.equal(authorization, "Bearer provider-single-expired-key");
return new Response(JSON.stringify({ error: { message: "expired provider token" } }), {
status: 401,
headers: { "content-type": "application/json" },
});
};
const response = await providerImageRoute.POST(
new Request("http://localhost/api/v1/providers/openai/images/generations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "gpt-image-2", prompt: "normalize provider terminal 401" }),
}),
{ params: Promise.resolve({ provider: "openai" }) }
);
const body = (await response.json()) as ErrorResponseBody;
assert.equal(response.status, 401);
assert.deepEqual(body.error, {
message: "expired provider token",
type: "authentication_error",
code: "invalid_api_key",
});
});
test("v1 image generation POST refreshes an expired Antigravity token before dispatch", async () => {
await seedConnection("antigravity", {
authType: "oauth",

View File

@@ -123,7 +123,7 @@ test("handleSearch builds Brave news requests and normalizes favicon metadata",
}
});
test("handleSearch builds Exa requests with include/exclude domains and preserves rich result fields", async () => {
test("handleSearch builds Exa requests with contents-nested options, include/exclude domains, and preserves rich result fields", async () => {
const originalFetch = globalThis.fetch;
let captured;
@@ -165,8 +165,7 @@ test("handleSearch builds Exa requests with include/exclude domains and preserve
query: "agentic workflows",
numResults: 5,
type: "auto",
text: true,
highlights: true,
contents: { text: true, highlights: true },
includeDomains: ["allowed.com"],
excludeDomains: ["blocked.com"],
category: "news",