From 3f47b88173e4ad11e08ec4baa20ea1419da00ab3 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Fri, 5 Jun 2026 22:28:25 +0200 Subject: [PATCH 1/8] feat: add backend api compatibility contract --- .../skills/backend-api-compatibility/SKILL.md | 59 +++++++ AGENTS.md | 1 + backend/.env.example | 4 + backend/package.json | 1 + backend/src/lib/compatibility.test.ts | 76 +++++++++ backend/src/lib/compatibility.ts | 158 ++++++++++++++++++ backend/src/lib/proxy.ts | 16 +- backend/src/pages/api/compatibility.ts | 9 + backend/src/pages/api/health.ts | 3 +- backend/src/pages/api/v1/active-model.ts | 4 +- package.json | 2 +- src/docs/backend-api-compatibility.md | 60 +++++++ src/electron/ai.ts | 22 ++- src/electron/nevermind-api.ts | 14 ++ src/electron/nevermind-auth.ts | 7 +- src/electron/nevermind-compatibility.ts | 51 ++++++ 16 files changed, 479 insertions(+), 8 deletions(-) create mode 100644 .agents/skills/backend-api-compatibility/SKILL.md create mode 100644 backend/src/lib/compatibility.test.ts create mode 100644 backend/src/lib/compatibility.ts create mode 100644 backend/src/pages/api/compatibility.ts create mode 100644 src/docs/backend-api-compatibility.md create mode 100644 src/electron/nevermind-api.ts create mode 100644 src/electron/nevermind-compatibility.ts diff --git a/.agents/skills/backend-api-compatibility/SKILL.md b/.agents/skills/backend-api-compatibility/SKILL.md new file mode 100644 index 0000000..f843960 --- /dev/null +++ b/.agents/skills/backend-api-compatibility/SKILL.md @@ -0,0 +1,59 @@ +--- +name: backend-api-compatibility +description: Use when changing or reviewing Nevermind desktop/backend contracts: Astro API routes used by desktop, Electron backend fetches, auth/device login, token revoke, active-model descriptors, AI proxy routes, billing/rate-limit/error shapes, compatibility manifests, feature flags, backend deploy policy, desktop release/update interactions, or any request about keeping frontend and backend in sync. +--- + +# Backend API Compatibility + +Nevermind's Electron desktop app ships on tagged releases while the backend may deploy continuously. Treat the backend as a backwards-compatible service for installed desktop clients. + +## Start here + +1. Read `src/docs/backend-api-compatibility.md`. +2. Map both sides of the contract before changing code: + - Desktop callers in `src/electron/nevermind-auth.ts`, `src/electron/ai.ts`, and related main-process flows. + - Backend routes in `backend/src/pages/api/**` and shared backend libraries in `backend/src/lib/**`. +3. Identify the change type: additive, feature-gated, compatibility shim, API-major change, or intentional unsupported-client block. +4. Prefer compatibility gates and additive fields over lockstep frontend/backend releases. + +## Contract rules + +- `/api/v1/*` is stable for supported desktop clients. +- Missing optional desktop headers must not break older clients. +- Unknown JSON fields must be safe for older desktop clients. +- Error shapes, auth semantics, billing behavior, and streaming semantics are part of the contract. +- Backend request identity headers are observability and compatibility metadata, never authentication. +- A backend-only change must not require a not-yet-installed desktop release unless it is gated or returns an explicit update requirement. + +## Required review questions + +- Which released desktop versions can call this route? +- What happens if the desktop does not send the new field/header? +- What happens if the backend returns an unknown field/error? +- Is the change safe under continuous backend deploys? +- Does the user get a palette-safe update/account action if the client is unsupported? +- Do logs identify desktop version, API contract version, route, status, and request ID? + +## Verification expectations + +For implementation work, add or update contract coverage for: + +- compatibility manifest shape +- unsupported-client response shape +- device auth initiation/exchange when changed +- token revoke when changed +- active-model descriptor shape when changed +- AI proxy success/error/rate-limit/billing responses when changed +- streaming response behavior when changed + +Run package commands through `mise exec pnpm` as required by the repo guidelines. + +## Output expectations + +When reporting compatibility work, include: + +- affected desktop callers and backend routes +- whether the change is additive, gated, breaking, or a shim +- supported desktop versions considered +- contract tests or manual verification performed +- residual rollout or sunset risks diff --git a/AGENTS.md b/AGENTS.md index b6131cb..b63de3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ * Keep files small and focused. Refactor slow patterns when encountered. * Maintain native behavioral contracts (shortcuts, icons, async lifecycle) when migrating features to extensions. * Keep every action/search/view payload that crosses Electron IPC `structuredClone`-safe; strip handlers/functions after registering them and add clone-safety checks for new payload shapes. +* Keep desktop/backend API changes backward-compatible for supported released clients; see `src/docs/backend-api-compatibility.md`. * When fixing bugs, evaluate how the system would look if built from scratch and propose improvements. ## Product and UX diff --git a/backend/.env.example b/backend/.env.example index b346a41..3929127 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,3 +18,7 @@ MAX_INPUT_TOKENS=100000 SENTRY_DSN= CRON_SECRET= + +NEVERMIND_MIN_DESKTOP_VERSION=0.0.0 +NEVERMIND_LATEST_DESKTOP_VERSION= +NEVERMIND_DESKTOP_UPDATE_URL=https://github.com/pablopunk/nvm/releases/latest diff --git a/backend/package.json b/backend/package.json index 2002123..a19a965 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,6 +9,7 @@ "dev": "astro dev", "build": "tsx scripts/migrate.ts && astro build", "preview": "astro preview", + "test": "node --import tsx --test \"src/**/*.test.ts\"", "astro": "astro", "db:generate": "drizzle-kit generate", "db:migrate": "tsx scripts/migrate.ts", diff --git a/backend/src/lib/compatibility.test.ts b/backend/src/lib/compatibility.test.ts new file mode 100644 index 0000000..aef345c --- /dev/null +++ b/backend/src/lib/compatibility.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { + compareVersions, + compatibilityError, + compatibilityManifestForRequest, + desktopClientFromRequest, + unsupportedClientReason, +} from './compatibility'; + +afterEach(() => { + delete process.env.NEVERMIND_MIN_DESKTOP_VERSION; + delete process.env.NEVERMIND_LATEST_DESKTOP_VERSION; + delete process.env.NEVERMIND_DESKTOP_UPDATE_URL; +}); + +test('parses desktop compatibility headers', () => { + const request = new Request('https://api.nvm.fyi/api/compatibility', { + headers: { + 'x-nevermind-client': 'desktop', + 'x-nevermind-client-version': '0.6.0', + 'x-nevermind-api-version': '1', + 'x-nevermind-platform': 'darwin', + 'x-nevermind-arch': 'arm64', + }, + }); + + assert.deepEqual(desktopClientFromRequest(request), { + name: 'desktop', + version: '0.6.0', + apiVersion: 1, + platform: 'darwin', + arch: 'arm64', + }); +}); + +test('keeps older clients without headers compatible by default', () => { + const request = new Request('https://api.nvm.fyi/api/compatibility'); + const manifest = compatibilityManifestForRequest(request); + + assert.equal(manifest.client.compatible, true); + assert.equal(manifest.client.unsupportedReason, null); + assert.equal(manifest.api.currentVersion, 1); + assert.deepEqual(manifest.api.supportedVersions, [1]); +}); + +test('detects unsupported desktop versions and API versions', () => { + process.env.NEVERMIND_MIN_DESKTOP_VERSION = '0.6.0'; + + assert.equal(unsupportedClientReason({ name: 'desktop', version: '0.5.9', apiVersion: 1, platform: 'darwin', arch: 'arm64' }), 'unsupported_desktop_version'); + assert.equal(unsupportedClientReason({ name: 'desktop', version: '0.6.0', apiVersion: 2, platform: 'darwin', arch: 'arm64' }), 'unsupported_api_version'); +}); + +test('compares semver-like desktop versions', () => { + assert.equal(compareVersions('v0.6.0', '0.6.0'), 0); + assert.equal(compareVersions('0.6.1', '0.6.0'), 1); + assert.equal(compareVersions('0.5.9', '0.6.0'), -1); +}); + +test('returns a stable unsupported-client error shape', async () => { + process.env.NEVERMIND_MIN_DESKTOP_VERSION = '0.6.0'; + process.env.NEVERMIND_LATEST_DESKTOP_VERSION = '0.7.0'; + process.env.NEVERMIND_DESKTOP_UPDATE_URL = 'https://example.com/update'; + + const response = compatibilityError(new Request('https://api.nvm.fyi/api/v1/active-model', { + headers: { 'x-request-id': 'req_123' }, + })); + const body = await response.json() as any; + + assert.equal(response.status, 426); + assert.equal(response.headers.get('x-request-id'), 'req_123'); + assert.equal(body.error.type, 'unsupported_client'); + assert.equal(body.error.minimum_supported_desktop_version, '0.6.0'); + assert.equal(body.error.latest_desktop_version, '0.7.0'); + assert.equal(body.error.update_url, 'https://example.com/update'); +}); diff --git a/backend/src/lib/compatibility.ts b/backend/src/lib/compatibility.ts new file mode 100644 index 0000000..db80c04 --- /dev/null +++ b/backend/src/lib/compatibility.ts @@ -0,0 +1,158 @@ +import { randomUUID } from 'node:crypto'; + +export const DESKTOP_API_VERSION = 1; +export const SUPPORTED_API_VERSIONS = [1] as const; + +export type DesktopClient = { + name: string | null; + version: string | null; + apiVersion: number | null; + platform: string | null; + arch: string | null; +}; + +export type CompatibilityManifest = { + backend: { + version: string; + environment: string; + }; + api: { + currentVersion: number; + supportedVersions: number[]; + }; + desktop: { + minimumSupportedVersion: string; + latestVersion: string | null; + updateUrl: string; + supportPolicy: string; + }; + client: DesktopClient & { + compatible: boolean; + unsupportedReason: string | null; + }; + features: Record; + notices: Array<{ type: 'info' | 'warning' | 'force_update'; message: string }>; +}; + +const DEFAULT_UPDATE_URL = 'https://github.com/pablopunk/nvm/releases/latest'; +const SUPPORT_POLICY = 'latest_two_minor_versions_or_90_days'; + +export function backendVersion() { + return process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) || 'dev'; +} + +export function backendEnvironment() { + return process.env.VERCEL_ENV || process.env.NODE_ENV || 'development'; +} + +export function minimumSupportedDesktopVersion() { + return process.env.NEVERMIND_MIN_DESKTOP_VERSION || '0.0.0'; +} + +export function latestDesktopVersion() { + return process.env.NEVERMIND_LATEST_DESKTOP_VERSION || null; +} + +export function desktopUpdateUrl() { + return process.env.NEVERMIND_DESKTOP_UPDATE_URL || DEFAULT_UPDATE_URL; +} + +export function requestIdFromHeaders(headers: Headers) { + return headers.get('x-request-id') || headers.get('x-nevermind-request-id') || randomUUID(); +} + +export function desktopClientFromRequest(request: Request): DesktopClient { + return { + name: blankToNull(request.headers.get('x-nevermind-client')), + version: blankToNull(request.headers.get('x-nevermind-client-version')), + apiVersion: parsePositiveInteger(request.headers.get('x-nevermind-api-version')), + platform: blankToNull(request.headers.get('x-nevermind-platform')), + arch: blankToNull(request.headers.get('x-nevermind-arch')), + }; +} + +export function compatibilityManifestForRequest(request: Request): CompatibilityManifest { + const client = desktopClientFromRequest(request); + const unsupportedReason = unsupportedClientReason(client); + return { + backend: { + version: backendVersion(), + environment: backendEnvironment(), + }, + api: { + currentVersion: DESKTOP_API_VERSION, + supportedVersions: [...SUPPORTED_API_VERSIONS], + }, + desktop: { + minimumSupportedVersion: minimumSupportedDesktopVersion(), + latestVersion: latestDesktopVersion(), + updateUrl: desktopUpdateUrl(), + supportPolicy: SUPPORT_POLICY, + }, + client: { + ...client, + compatible: !unsupportedReason, + unsupportedReason, + }, + features: {}, + notices: [], + }; +} + +export function compatibilityHeaders(requestId?: string) { + const headers = new Headers(); + headers.set('x-nevermind-backend-version', backendVersion()); + if (requestId) headers.set('x-request-id', requestId); + return headers; +} + +export function unsupportedClientReason(client: DesktopClient) { + if (client.apiVersion !== null && !SUPPORTED_API_VERSIONS.includes(client.apiVersion as 1)) return 'unsupported_api_version'; + if (client.version && compareVersions(client.version, minimumSupportedDesktopVersion()) < 0) return 'unsupported_desktop_version'; + return null; +} + +export function compatibilityError(request: Request, message = 'This version of Nevermind is no longer supported.') { + const requestId = requestIdFromHeaders(request.headers); + return Response.json( + { + error: { + type: 'unsupported_client', + message, + minimum_supported_desktop_version: minimumSupportedDesktopVersion(), + latest_desktop_version: latestDesktopVersion(), + update_url: desktopUpdateUrl(), + request_id: requestId, + }, + }, + { status: 426, headers: compatibilityHeaders(requestId) }, + ); +} + +export function compareVersions(left: string, right: string) { + const leftParts = versionParts(left); + const rightParts = versionParts(right); + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { + const diff = (leftParts[index] || 0) - (rightParts[index] || 0); + if (diff !== 0) return diff > 0 ? 1 : -1; + } + return 0; +} + +function versionParts(version: string) { + return String(version || '') + .replace(/^v/i, '') + .split(/[.-]/) + .map((part) => Number.parseInt(part, 10)) + .filter((part) => Number.isFinite(part)); +} + +function blankToNull(value: string | null) { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function parsePositiveInteger(value: string | null) { + const parsed = Number.parseInt(value || '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} diff --git a/backend/src/lib/proxy.ts b/backend/src/lib/proxy.ts index f5113a0..85be628 100644 --- a/backend/src/lib/proxy.ts +++ b/backend/src/lib/proxy.ts @@ -19,6 +19,7 @@ import { getUpstreamConfig, UpstreamConfigError } from './upstream'; import { extractPatFromHeaders, getUserFromHeaders, type PatHeaderName } from './tokens'; import { rateLimitChat, tooManyRequests } from './ratelimit'; import { estimateInputTokensFromBody, estimatePromptCredits, MAX_INPUT_TOKENS } from './limits'; +import { backendVersion, desktopClientFromRequest, type DesktopClient } from './compatibility'; import { log } from './log'; import * as Sentry from '@sentry/astro'; @@ -50,6 +51,7 @@ type BillContext = { costRow: ModelCost; kind: 'free' | 'paid'; requestId: string; + client: DesktopClient; }; async function recordUsage(ctx: BillContext, tokens: UsageTokens, status: number, latencyMs: number) { @@ -92,6 +94,11 @@ async function recordUsage(ctx: BillContext, tokens: UsageTokens, status: number input_tokens: tokens.inputTokens, output_tokens: tokens.outputTokens, cost_credits: billable ? credits : 0, + client_name: ctx.client.name, + client_version: ctx.client.version, + client_api_version: ctx.client.apiVersion, + client_platform: ctx.client.platform, + client_arch: ctx.client.arch, }); } @@ -193,6 +200,7 @@ function buildForwardHeaders( for (const [name, value] of request.headers) { const lower = name.toLowerCase(); if (HOP_BY_HOP.has(lower)) continue; + if (lower === 'x-request-id' || lower.startsWith('x-nevermind-')) continue; if (lower === desktopAuthHeader) continue; if (lower === upstreamAuthHeader) continue; out.set(name, value); @@ -208,20 +216,24 @@ function isStreamingContentType(contentType: string | null): boolean { function withRequestId(res: Response, requestId: string): Response { res.headers.set('x-request-id', requestId); + res.headers.set('x-nevermind-backend-version', backendVersion()); return res; } export async function proxyAndBill(cfg: ProxyConfig): Promise { const requestId = randomUUID(); const startedAt = Date.now(); + const client = desktopClientFromRequest(cfg.request); Sentry.getCurrentScope().setTag('request_id', requestId); + if (client.version) Sentry.getCurrentScope().setTag('client_version', client.version); + if (client.apiVersion) Sentry.getCurrentScope().setTag('client_api_version', String(client.apiVersion)); const routing = await resolveRouting(cfg.request, cfg.authHeaderName); if (routing instanceof Response) return withRequestId(routing, requestId); Sentry.getCurrentScope().setUser({ id: routing.user.id }); const rateDecision = await rateLimitChat(routing.user.id, routing.kind); if (!rateDecision.ok) { - log.warn('rate_limited', { request_id: requestId, user_id: routing.user.id, scope: rateDecision.scope }); + log.warn('rate_limited', { request_id: requestId, user_id: routing.user.id, scope: rateDecision.scope, client_version: client.version, client_api_version: client.apiVersion }); return withRequestId(tooManyRequests(rateDecision), requestId); } @@ -270,10 +282,12 @@ export async function proxyAndBill(cfg: ProxyConfig): Promise { costRow: routing.costRow, kind: routing.kind, requestId, + client, }; const responseHeaders = stripHopByHop(upstreamResponse.headers); responseHeaders.set('x-request-id', requestId); + responseHeaders.set('x-nevermind-backend-version', backendVersion()); if (!upstreamResponse.ok) { const latencyMs = Date.now() - startedAt; diff --git a/backend/src/pages/api/compatibility.ts b/backend/src/pages/api/compatibility.ts new file mode 100644 index 0000000..bc6e295 --- /dev/null +++ b/backend/src/pages/api/compatibility.ts @@ -0,0 +1,9 @@ +import type { APIRoute } from 'astro'; +import { compatibilityHeaders, compatibilityManifestForRequest, requestIdFromHeaders } from '../../lib/compatibility'; + +export const GET: APIRoute = ({ request }) => { + const requestId = requestIdFromHeaders(request.headers); + return Response.json(compatibilityManifestForRequest(request), { + headers: compatibilityHeaders(requestId), + }); +}; diff --git a/backend/src/pages/api/health.ts b/backend/src/pages/api/health.ts index 2a4fdb0..79c1a69 100644 --- a/backend/src/pages/api/health.ts +++ b/backend/src/pages/api/health.ts @@ -3,6 +3,7 @@ import { sql } from 'drizzle-orm'; import { db } from '../../db/client'; import { getUpstreamConfig } from '../../lib/upstream'; import { getActiveProvider } from '../../lib/settings'; +import { compatibilityHeaders } from '../../lib/compatibility'; import { log } from '../../lib/log'; const VERSION = process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? 'dev'; @@ -45,6 +46,6 @@ export const GET: APIRoute = async () => { const ok = dbOk && upstreamOk; return Response.json( { ok, db: dbOk, upstream: upstreamOk, version: VERSION }, - { status: ok ? 200 : 503 }, + { status: ok ? 200 : 503, headers: compatibilityHeaders() }, ); }; diff --git a/backend/src/pages/api/v1/active-model.ts b/backend/src/pages/api/v1/active-model.ts index cf12a87..ce47dc1 100644 --- a/backend/src/pages/api/v1/active-model.ts +++ b/backend/src/pages/api/v1/active-model.ts @@ -3,6 +3,7 @@ import { getUserFromBearer } from '../../../lib/tokens'; import { getActiveModelId, getFreeModelId, getActiveProvider, ModelNotConfiguredError } from '../../../lib/settings'; import { getBalances } from '../../../lib/users'; import { lookupModelDescriptor } from '../../../lib/pricing'; +import { compatibilityHeaders, requestIdFromHeaders } from '../../../lib/compatibility'; import { selectApiForModel, type UpstreamApi } from '../../../lib/upstream'; const NEVERMIND_PROVIDER_ID = 'nevermind'; @@ -43,10 +44,11 @@ export const GET: APIRoute = async ({ request }) => { const api = selectApiForModel(provider, modelId); const baseUrl = backendBaseUrlForApi(new URL(request.url), api); + const requestId = requestIdFromHeaders(request.headers); return Response.json({ ...descriptor, api, provider: NEVERMIND_PROVIDER_ID, baseUrl, - }); + }, { headers: compatibilityHeaders(requestId) }); }; diff --git a/package.json b/package.json index 3c78b71..6a168a8 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev": "node ./scripts/electron-dev.cjs", "build": "electron-vite build", "typecheck": "tsc -p tsconfig.check.json", - "test": "pnpm build && node --check dist/main/main.js && node --check dist/preload/preload.cjs && node scripts/check-design-system.cjs && node scripts/check-internal-extensions.cjs && node scripts/check-clone-safe-actions.cjs && node scripts/check-packaged-resources.cjs && pnpm typecheck", + "test": "pnpm build && node --check dist/main/main.js && node --check dist/preload/preload.cjs && node scripts/check-design-system.cjs && node scripts/check-internal-extensions.cjs && node scripts/check-clone-safe-actions.cjs && node scripts/check-packaged-resources.cjs && pnpm typecheck && pnpm -C backend test", "palette:debug": "node .agents/skills/palette-debug/palette-debug.cjs", "learnings:export": "node scripts/learnings-export.cjs", "logs:tail": "node scripts/tail-log.cjs", diff --git a/src/docs/backend-api-compatibility.md b/src/docs/backend-api-compatibility.md new file mode 100644 index 0000000..8909f85 --- /dev/null +++ b/src/docs/backend-api-compatibility.md @@ -0,0 +1,60 @@ +# Backend API compatibility + +Nevermind's desktop app and backend do not deploy at the same cadence. Desktop users install tagged Electron releases when they choose to update; the backend may deploy continuously from `main`. The backend must therefore treat every supported desktop release as an active client. + +## Product contract + +- Backend deploys must be safe for supported installed desktop versions. +- Desktop/backend synchronization is achieved through API contracts, not lockstep releases. +- `/api/v1/*` is the current stable desktop API surface. +- Changes inside an API major version are additive by default. +- Breaking changes require either a new API major version or a staged compatibility gate. +- Unsupported clients should receive an explicit update path, never an unexplained AI/auth failure. + +## Supported-client policy + +Support at least the latest two minor desktop versions or the last 90 days of released desktop versions, whichever keeps more users covered. Patch releases in a supported minor line remain supported. + +Emergency security blocks may shorten the window, but the backend must return a clear compatibility response that lets desktop render an update prompt. + +## Desktop request identity + +Desktop backend requests should include non-secret identity metadata: app name, desktop app version, requested API contract version, platform, architecture, and an optional request ID. + +These values are for compatibility, observability, support, and feature gating. They are not authentication and must not replace token/session checks. + +## Compatibility manifest + +The backend owns a small manifest that desktop can fetch during startup, sign-in, and AI setup. The manifest should describe the backend deployment, current and supported API majors, minimum supported desktop version, latest known desktop version, available feature flags, and any deprecation or force-update notices. + +Desktop should cache the last successful manifest, show cached state immediately, and refresh in place. + +## Compatibility errors + +Compatibility failures should use a consistent JSON shape with a machine-readable error type, human-readable message, minimum supported desktop version, latest known desktop version when available, update URL, and request ID. + +Desktop should translate these responses into palette-safe account/update UI and reuse the existing update actions where possible. + +## Feature rollout + +New backend capabilities should be gated by explicit manifest features, not inferred from backend deploy versions. Desktop should ignore unknown features and only enable new behavior when the expected feature is present. + +Server-side kill switches should exist for risky behavior such as model provider changes, streaming transformations, billing enforcement changes, and auth flow changes. + +## Contract testing + +Every backend route used by desktop is part of the product contract. Contract tests should cover successful responses, error shapes, auth/device flows, active-model descriptors, proxy routes, streaming semantics, rate limits, and unsupported-client responses. + +When a desktop release is tagged, preserve the request/response expectations needed to keep that release supported through the support window. + +## Breaking-change checklist + +Before changing a backend route used by desktop, decide whether the change is: + +1. Additive and safe for older clients. +2. Gated by a feature flag or desktop version. +3. A new API major version. +4. A compatibility shim that keeps old clients working. +5. An intentional unsupported-client block with update UX. + +If none of these apply, do not ship the backend change. diff --git a/src/electron/ai.ts b/src/electron/ai.ts index 3b29ea4..f9d2137 100644 --- a/src/electron/ai.ts +++ b/src/electron/ai.ts @@ -4,6 +4,8 @@ import { pathToFileURL } from 'node:url' import ts from 'typescript' import * as logger from './logger' import { readRecentLogs, type LogLevel, type LogSource } from './logger' +import { checkNevermindCompatibility } from './nevermind-compatibility' +import { nevermindDesktopHeaders } from './nevermind-api' import { getNevermindAuth, NevermindAuthRequiredError } from './nevermind-auth' type AiEvent = { @@ -18,7 +20,7 @@ type AiEvent = { } type AiLimitNotice = { - kind: 'insufficient_credits' | 'rate_limited' | 'prompt_too_large' + kind: 'insufficient_credits' | 'rate_limited' | 'prompt_too_large' | 'unsupported_client' title: string message: string actionTitle?: string @@ -27,6 +29,7 @@ type AiLimitNotice = { } const NEVERMIND_DASHBOARD_URL = 'https://nvm.fyi/dashboard' +const NEVERMIND_UPDATE_URL = 'https://github.com/pablopunk/nvm/releases/latest' type ActiveChat = { id?: string @@ -404,9 +407,22 @@ function aiLimitNoticeFromError(error: unknown): AiLimitNotice | null { dashboardUrl: NEVERMIND_DASHBOARD_URL, } } + if (/unsupported[_ -]?client|unsupported[_ -]?desktop|unsupported[_ -]?api|no longer supported|426\b/i.test(text)) { + return { + kind: 'unsupported_client', + title: 'Update Nevermind', + message: 'This version of Nevermind is no longer supported by the backend. Install the latest version to keep using AI features.', + actionTitle: 'Download Update', + dashboardUrl: updateUrlFromErrorText(text) || NEVERMIND_UPDATE_URL, + } + } return null } +function updateUrlFromErrorText(text: string) { + return text.match(/https:\/\/[^\s"'}]+/i)?.[0] +} + function searchableErrorText(error: unknown) { if (!error) return '' if (!(error instanceof Error)) return stringifyError(error) @@ -474,8 +490,9 @@ type BackendDescriptor = { async function fetchActiveModelDescriptor(baseUrl: string, token: string): Promise { const trimmed = baseUrl.replace(/\/$/, '') + await checkNevermindCompatibility(trimmed) const res = await fetch(`${trimmed}/api/v1/active-model`, { - headers: { Authorization: `Bearer ${token}` }, + headers: nevermindDesktopHeaders({ Authorization: `Bearer ${token}` }), }) if (res.status === 401) throw new NevermindAuthRequiredError() if (!res.ok) { @@ -501,6 +518,7 @@ async function resolveAiModelAndAuth(_pi: any, _ai: any, authStorage: any) { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: descriptor.contextWindow, maxTokens: descriptor.maxTokens, + headers: nevermindDesktopHeaders(), } return { model, source: 'nevermind' as const } } diff --git a/src/electron/nevermind-api.ts b/src/electron/nevermind-api.ts new file mode 100644 index 0000000..e13ff5f --- /dev/null +++ b/src/electron/nevermind-api.ts @@ -0,0 +1,14 @@ +import { app } from 'electron' + +export const NEVERMIND_DESKTOP_API_VERSION = '1' + +export function nevermindDesktopHeaders(headers: Record = {}) { + return { + 'X-Nevermind-Client': 'desktop', + 'X-Nevermind-Client-Version': app.getVersion(), + 'X-Nevermind-API-Version': NEVERMIND_DESKTOP_API_VERSION, + 'X-Nevermind-Platform': process.platform, + 'X-Nevermind-Arch': process.arch, + ...headers, + } +} diff --git a/src/electron/nevermind-auth.ts b/src/electron/nevermind-auth.ts index 1cbf16a..cc57771 100644 --- a/src/electron/nevermind-auth.ts +++ b/src/electron/nevermind-auth.ts @@ -3,6 +3,8 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import * as logger from './logger' +import { checkNevermindCompatibility } from './nevermind-compatibility' +import { nevermindDesktopHeaders } from './nevermind-api' const FILENAME = 'nevermind-auth.json' @@ -96,7 +98,7 @@ export async function signOutFromNevermind(): Promise<{ revoked: boolean }> { try { const res = await fetch(`${current.baseUrl}/api/tokens/current`, { method: 'DELETE', - headers: { Authorization: `Bearer ${current.token}`, Origin: current.baseUrl }, + headers: nevermindDesktopHeaders({ Authorization: `Bearer ${current.token}`, Origin: current.baseUrl }), }) revoked = res.ok || res.status === 401 if (!revoked) logger.warn(`token revoke returned ${res.status}`) @@ -126,7 +128,7 @@ function defaultDeviceLabel() { async function postJson(url: string, body: unknown) { return fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: nevermindDesktopHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(body), }) } @@ -140,6 +142,7 @@ export async function signInToNevermind({ baseUrl = DEFAULT_BASE_URL, label = de const trimmedBase = normalizedBaseUrl(baseUrl) activeSignIn = (async (): Promise => { try { + await checkNevermindCompatibility(trimmedBase) const initRes = await postJson(`${trimmedBase}/api/auth/device/initiate`, { label }) if (!initRes.ok) return { ok: false, error: `initiate failed: ${initRes.status}` } const { code, verifyUrl, expiresAt, pollIntervalMs } = (await initRes.json()) as { code: string; verifyUrl: string; expiresAt: string; pollIntervalMs?: number } diff --git a/src/electron/nevermind-compatibility.ts b/src/electron/nevermind-compatibility.ts new file mode 100644 index 0000000..b21594f --- /dev/null +++ b/src/electron/nevermind-compatibility.ts @@ -0,0 +1,51 @@ +import * as logger from './logger' +import { nevermindDesktopHeaders } from './nevermind-api' + +export type NevermindCompatibilityManifest = { + client?: { + compatible?: boolean + unsupportedReason?: string | null + } + desktop?: { + minimumSupportedVersion?: string + latestVersion?: string | null + updateUrl?: string + } +} + +export class NevermindCompatibilityError extends Error { + updateUrl?: string + minimumSupportedVersion?: string + latestVersion?: string | null + unsupportedReason?: string | null + + constructor(manifest: NevermindCompatibilityManifest) { + const minimum = manifest.desktop?.minimumSupportedVersion + super(minimum ? `This Nevermind version is no longer supported. Please update to ${minimum} or newer.` : 'This Nevermind version is no longer supported. Please update Nevermind.') + this.name = 'NevermindCompatibilityError' + this.updateUrl = manifest.desktop?.updateUrl + this.minimumSupportedVersion = minimum + this.latestVersion = manifest.desktop?.latestVersion + this.unsupportedReason = manifest.client?.unsupportedReason + } +} + +export async function checkNevermindCompatibility(baseUrl: string) { + const trimmed = baseUrl.replace(/\/$/, '') + let res: Response + try { + res = await fetch(`${trimmed}/api/compatibility`, { headers: nevermindDesktopHeaders() }) + } catch (error) { + logger.warn('nevermind.compatibility.fetch.failed', error as Error) + return null + } + if (res.status === 404) return null + if (!res.ok) { + logger.warn('nevermind.compatibility.unavailable', { status: res.status }) + return null + } + const manifest = (await res.json().catch(() => null)) as NevermindCompatibilityManifest | null + if (!manifest) return null + if (manifest.client?.compatible === false) throw new NevermindCompatibilityError(manifest) + return manifest +} From 58285db3e8e29d8e628205a583383bf422e4c4eb Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Fri, 5 Jun 2026 22:37:33 +0200 Subject: [PATCH 2/8] fix: install backend deps in ci --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a94502..8fba1ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,9 @@ jobs: uses: jdx/mise-action@v2 - name: Install dependencies - run: mise exec -- pnpm install --frozen-lockfile + run: | + mise exec -- pnpm install --frozen-lockfile + mise exec -- pnpm -C backend install --frozen-lockfile - name: Test app run: mise exec -- pnpm test From 6c82c322f04dc965e44119f060838172d7dc4297 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Fri, 5 Jun 2026 23:01:07 +0200 Subject: [PATCH 3/8] feat: continue compatibility lifecycle phases --- backend/.env.example | 3 + .../desktop-v1/compatibility-manifest.json | 29 +++++ .../desktop-v1/unsupported-client-error.json | 10 ++ backend/src/lib/compatibility.test.ts | 67 ++++++++++- backend/src/lib/compatibility.ts | 76 +++++++++++- backend/src/pages/api/compatibility.ts | 2 +- src/docs/backend-api-compatibility.md | 10 ++ src/electron/ai.ts | 5 +- src/electron/main.ts | 33 +++++- src/electron/nevermind-compatibility.ts | 110 ++++++++++++++++-- src/extension-view.tsx | 7 +- src/use-ai-chat.ts | 4 +- 12 files changed, 335 insertions(+), 21 deletions(-) create mode 100644 backend/src/fixtures/contracts/desktop-v1/compatibility-manifest.json create mode 100644 backend/src/fixtures/contracts/desktop-v1/unsupported-client-error.json diff --git a/backend/.env.example b/backend/.env.example index 3929127..41d849e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,3 +22,6 @@ CRON_SECRET= NEVERMIND_MIN_DESKTOP_VERSION=0.0.0 NEVERMIND_LATEST_DESKTOP_VERSION= NEVERMIND_DESKTOP_UPDATE_URL=https://github.com/pablopunk/nvm/releases/latest +# Comma list: feature_a,feature_b or JSON rules: +# {"feature_a":true,"feature_b":{"minDesktopVersion":"0.7.0","rolloutPercent":25}} +NEVERMIND_FEATURE_FLAGS= diff --git a/backend/src/fixtures/contracts/desktop-v1/compatibility-manifest.json b/backend/src/fixtures/contracts/desktop-v1/compatibility-manifest.json new file mode 100644 index 0000000..d957a0b --- /dev/null +++ b/backend/src/fixtures/contracts/desktop-v1/compatibility-manifest.json @@ -0,0 +1,29 @@ +{ + "backend": { + "version": "abcdef1", + "environment": "preview" + }, + "api": { + "currentVersion": 1, + "supportedVersions": [1] + }, + "desktop": { + "minimumSupportedVersion": "0.6.0", + "latestVersion": "0.7.0", + "updateUrl": "https://example.com/update", + "supportPolicy": "latest_two_minor_versions_or_90_days" + }, + "client": { + "name": "desktop", + "version": "0.6.1", + "apiVersion": 1, + "platform": "darwin", + "arch": "arm64", + "compatible": true, + "unsupportedReason": null + }, + "features": { + "streaming_v2": true + }, + "notices": [] +} diff --git a/backend/src/fixtures/contracts/desktop-v1/unsupported-client-error.json b/backend/src/fixtures/contracts/desktop-v1/unsupported-client-error.json new file mode 100644 index 0000000..06f3370 --- /dev/null +++ b/backend/src/fixtures/contracts/desktop-v1/unsupported-client-error.json @@ -0,0 +1,10 @@ +{ + "error": { + "type": "unsupported_client", + "message": "This version of Nevermind is no longer supported.", + "minimum_supported_desktop_version": "0.6.0", + "latest_desktop_version": "0.7.0", + "update_url": "https://example.com/update", + "request_id": "req_123" + } +} diff --git a/backend/src/lib/compatibility.test.ts b/backend/src/lib/compatibility.test.ts index aef345c..c916df0 100644 --- a/backend/src/lib/compatibility.test.ts +++ b/backend/src/lib/compatibility.test.ts @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { afterEach, test } from 'node:test'; import { compareVersions, compatibilityError, + compatibilityFeaturesForClient, compatibilityManifestForRequest, desktopClientFromRequest, unsupportedClientReason, @@ -12,8 +14,15 @@ afterEach(() => { delete process.env.NEVERMIND_MIN_DESKTOP_VERSION; delete process.env.NEVERMIND_LATEST_DESKTOP_VERSION; delete process.env.NEVERMIND_DESKTOP_UPDATE_URL; + delete process.env.NEVERMIND_FEATURE_FLAGS; + delete process.env.VERCEL_GIT_COMMIT_SHA; + delete process.env.VERCEL_ENV; }); +function fixture(name: string) { + return JSON.parse(readFileSync(new URL(`../fixtures/contracts/desktop-v1/${name}.json`, import.meta.url), 'utf8')); +} + test('parses desktop compatibility headers', () => { const request = new Request('https://api.nvm.fyi/api/compatibility', { headers: { @@ -57,6 +66,59 @@ test('compares semver-like desktop versions', () => { assert.equal(compareVersions('0.5.9', '0.6.0'), -1); }); +test('returns comma-list feature flags in the manifest', () => { + process.env.NEVERMIND_FEATURE_FLAGS = 'new_models,streaming_v2'; + const request = new Request('https://api.nvm.fyi/api/compatibility', { + headers: { 'x-nevermind-client-version': '0.6.0' }, + }); + + const manifest = compatibilityManifestForRequest(request, { requestId: 'req_flags' }); + + assert.deepEqual(manifest.features, { new_models: true, streaming_v2: true }); +}); + +test('evaluates version, user, plan, and rollout feature rules', () => { + const client = { name: 'desktop', version: '0.6.0', apiVersion: 1, platform: 'darwin', arch: 'arm64' }; + process.env.NEVERMIND_FEATURE_FLAGS = JSON.stringify({ + enabled: true, + needs_newer_desktop: { minDesktopVersion: '0.7.0' }, + allowed_user_plan: { users: ['user_1'], plans: ['pro'] }, + blocked_user_plan: { users: ['user_2'], plans: ['pro'] }, + zero_rollout: { rolloutPercent: 0 }, + full_rollout: { rolloutPercent: 100 }, + }); + + assert.deepEqual(compatibilityFeaturesForClient(client, { userId: 'user_1', plan: 'pro' }), { + enabled: true, + needs_newer_desktop: false, + allowed_user_plan: true, + blocked_user_plan: false, + zero_rollout: false, + full_rollout: true, + }); +}); + +test('matches the desktop-v1 compatibility manifest fixture', () => { + process.env.VERCEL_GIT_COMMIT_SHA = 'abcdef1234567890'; + process.env.VERCEL_ENV = 'preview'; + process.env.NEVERMIND_MIN_DESKTOP_VERSION = '0.6.0'; + process.env.NEVERMIND_LATEST_DESKTOP_VERSION = '0.7.0'; + process.env.NEVERMIND_DESKTOP_UPDATE_URL = 'https://example.com/update'; + process.env.NEVERMIND_FEATURE_FLAGS = 'streaming_v2'; + + const request = new Request('https://api.nvm.fyi/api/compatibility', { + headers: { + 'x-nevermind-client': 'desktop', + 'x-nevermind-client-version': '0.6.1', + 'x-nevermind-api-version': '1', + 'x-nevermind-platform': 'darwin', + 'x-nevermind-arch': 'arm64', + }, + }); + + assert.deepEqual(compatibilityManifestForRequest(request), fixture('compatibility-manifest')); +}); + test('returns a stable unsupported-client error shape', async () => { process.env.NEVERMIND_MIN_DESKTOP_VERSION = '0.6.0'; process.env.NEVERMIND_LATEST_DESKTOP_VERSION = '0.7.0'; @@ -69,8 +131,5 @@ test('returns a stable unsupported-client error shape', async () => { assert.equal(response.status, 426); assert.equal(response.headers.get('x-request-id'), 'req_123'); - assert.equal(body.error.type, 'unsupported_client'); - assert.equal(body.error.minimum_supported_desktop_version, '0.6.0'); - assert.equal(body.error.latest_desktop_version, '0.7.0'); - assert.equal(body.error.update_url, 'https://example.com/update'); + assert.deepEqual(body, fixture('unsupported-client-error')); }); diff --git a/backend/src/lib/compatibility.ts b/backend/src/lib/compatibility.ts index db80c04..3be0a95 100644 --- a/backend/src/lib/compatibility.ts +++ b/backend/src/lib/compatibility.ts @@ -1,4 +1,5 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; +import { log } from './log'; export const DESKTOP_API_VERSION = 1; export const SUPPORTED_API_VERSIONS = [1] as const; @@ -11,6 +12,22 @@ export type DesktopClient = { arch: string | null; }; +type FeatureFlagRule = boolean | { + enabled?: boolean; + minDesktopVersion?: string; + maxDesktopVersion?: string; + users?: string[]; + plans?: string[]; + rolloutPercent?: number; +}; + +export type FeatureFlagContext = { + userId?: string | null; + plan?: string | null; + requestId?: string | null; + route?: string; +}; + export type CompatibilityManifest = { backend: { version: string; @@ -71,9 +88,11 @@ export function desktopClientFromRequest(request: Request): DesktopClient { }; } -export function compatibilityManifestForRequest(request: Request): CompatibilityManifest { +export function compatibilityManifestForRequest(request: Request, context: FeatureFlagContext = {}): CompatibilityManifest { const client = desktopClientFromRequest(request); const unsupportedReason = unsupportedClientReason(client); + const features = compatibilityFeaturesForClient(client, context); + logFeatureEvaluations(features, client, context); return { backend: { version: backendVersion(), @@ -94,7 +113,7 @@ export function compatibilityManifestForRequest(request: Request): Compatibility compatible: !unsupportedReason, unsupportedReason, }, - features: {}, + features, notices: [], }; } @@ -106,6 +125,11 @@ export function compatibilityHeaders(requestId?: string) { return headers; } +export function compatibilityFeaturesForClient(client: DesktopClient, context: FeatureFlagContext = {}) { + const definitions = featureFlagDefinitions(); + return Object.fromEntries(Object.entries(definitions).map(([name, rule]) => [name, evaluateFeatureFlag(name, rule, client, context)])); +} + export function unsupportedClientReason(client: DesktopClient) { if (client.apiVersion !== null && !SUPPORTED_API_VERSIONS.includes(client.apiVersion as 1)) return 'unsupported_api_version'; if (client.version && compareVersions(client.version, minimumSupportedDesktopVersion()) < 0) return 'unsupported_desktop_version'; @@ -139,6 +163,52 @@ export function compareVersions(left: string, right: string) { return 0; } +function featureFlagDefinitions(): Record { + const raw = process.env.NEVERMIND_FEATURE_FLAGS?.trim(); + if (!raw) return {}; + if (!raw.startsWith('{')) { + return Object.fromEntries(raw.split(',').map((name) => [name.trim(), true]).filter(([name]) => Boolean(name))); + } + try { + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch (error) { + log.warn('feature_flags_parse_failed', { error }); + return {}; + } +} + +function evaluateFeatureFlag(name: string, rule: FeatureFlagRule, client: DesktopClient, context: FeatureFlagContext) { + if (typeof rule === 'boolean') return rule; + if (!rule || rule.enabled === false) return false; + if (rule.minDesktopVersion && (!client.version || compareVersions(client.version, rule.minDesktopVersion) < 0)) return false; + if (rule.maxDesktopVersion && (!client.version || compareVersions(client.version, rule.maxDesktopVersion) > 0)) return false; + if (rule.users?.length && (!context.userId || !rule.users.includes(context.userId))) return false; + if (rule.plans?.length && (!context.plan || !rule.plans.includes(context.plan))) return false; + if (typeof rule.rolloutPercent === 'number' && rolloutBucket(name, client, context) >= Math.max(0, Math.min(100, rule.rolloutPercent))) return false; + return true; +} + +function rolloutBucket(name: string, client: DesktopClient, context: FeatureFlagContext) { + const key = [name, context.userId, context.plan, client.name, client.version, client.platform, client.arch].filter(Boolean).join(':') || name; + const hex = createHash('sha256').update(key).digest('hex').slice(0, 8); + return Number.parseInt(hex, 16) % 100; +} + +function logFeatureEvaluations(features: Record, client: DesktopClient, context: FeatureFlagContext) { + if (!Object.keys(features).length) return; + log.info('feature_flags_evaluated', { + request_id: context.requestId || undefined, + route: context.route || 'compatibility', + user_id: context.userId || undefined, + plan: context.plan || undefined, + client_name: client.name, + client_version: client.version, + client_api_version: client.apiVersion, + features, + }); +} + function versionParts(version: string) { return String(version || '') .replace(/^v/i, '') diff --git a/backend/src/pages/api/compatibility.ts b/backend/src/pages/api/compatibility.ts index bc6e295..c3acac6 100644 --- a/backend/src/pages/api/compatibility.ts +++ b/backend/src/pages/api/compatibility.ts @@ -3,7 +3,7 @@ import { compatibilityHeaders, compatibilityManifestForRequest, requestIdFromHea export const GET: APIRoute = ({ request }) => { const requestId = requestIdFromHeaders(request.headers); - return Response.json(compatibilityManifestForRequest(request), { + return Response.json(compatibilityManifestForRequest(request, { requestId, route: 'compatibility' }), { headers: compatibilityHeaders(requestId), }); }; diff --git a/src/docs/backend-api-compatibility.md b/src/docs/backend-api-compatibility.md index 8909f85..8a425ba 100644 --- a/src/docs/backend-api-compatibility.md +++ b/src/docs/backend-api-compatibility.md @@ -39,8 +39,18 @@ Desktop should translate these responses into palette-safe account/update UI and New backend capabilities should be gated by explicit manifest features, not inferred from backend deploy versions. Desktop should ignore unknown features and only enable new behavior when the expected feature is present. +Feature flags may be returned by `GET /api/compatibility` in the `features` object. Backend configuration supports simple comma-list flags and JSON rules with desktop version, user, plan, and rollout constraints. Rollout percentages must be deterministic for a client/user so support can reason about why a user did or did not receive a feature. + Server-side kill switches should exist for risky behavior such as model provider changes, streaming transformations, billing enforcement changes, and auth flow changes. +## API-major breaking-change criteria + +Create a new API major, such as `/api/v2`, only when a backend change cannot be safely represented as an additive field, optional feature flag, compatibility shim, or explicit unsupported-client block inside the current major. + +A change is API-major breaking when it removes or renames a field used by a supported desktop release, changes an error type/status that desktop handles specially, changes auth or billing semantics, changes model descriptor/provider routing in a way old clients cannot understand, changes streaming framing or termination semantics, or requires desktop to send a new non-optional request field/header. + +Before retiring an API major, the backend owner must document the sunset date, verify active client counts are below the supported-client threshold, preserve user-visible update messaging, and keep compatibility errors available until the final sunset window closes. + ## Contract testing Every backend route used by desktop is part of the product contract. Contract tests should cover successful responses, error shapes, auth/device flows, active-model descriptors, proxy routes, streaming semantics, rate limits, and unsupported-client responses. diff --git a/src/electron/ai.ts b/src/electron/ai.ts index f9d2137..d9faedd 100644 --- a/src/electron/ai.ts +++ b/src/electron/ai.ts @@ -5,6 +5,7 @@ import ts from 'typescript' import * as logger from './logger' import { readRecentLogs, type LogLevel, type LogSource } from './logger' import { checkNevermindCompatibility } from './nevermind-compatibility' +import type { CommandAction } from '../model' import { nevermindDesktopHeaders } from './nevermind-api' import { getNevermindAuth, NevermindAuthRequiredError } from './nevermind-auth' @@ -25,6 +26,7 @@ type AiLimitNotice = { message: string actionTitle?: string dashboardUrl?: string + action?: CommandAction retryAfterSec?: number } @@ -412,8 +414,9 @@ function aiLimitNoticeFromError(error: unknown): AiLimitNotice | null { kind: 'unsupported_client', title: 'Update Nevermind', message: 'This version of Nevermind is no longer supported by the backend. Install the latest version to keep using AI features.', - actionTitle: 'Download Update', + actionTitle: 'Check for Update', dashboardUrl: updateUrlFromErrorText(text) || NEVERMIND_UPDATE_URL, + action: { type: 'checkForUpdates', title: 'Check for Update' }, } } return null diff --git a/src/electron/main.ts b/src/electron/main.ts index 3ddcf9c..cd0eee3 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -12,6 +12,7 @@ import { clipboardFilePath as readClipboardFilePath, clipboardFilePaths, clipboa import { configureLocalFileUrlSecret, expandUserPath, extensionForPath, fileUrlForPath, IMAGE_EXTENSIONS, isImagePath, isVideoPath, LOCAL_FILE_PROTOCOL, LOCAL_THUMB_PROTOCOL, thumbnailUrlForPath, verifyLocalFileToken, VIDEO_EXTENSIONS } from './file-utils' import { createNevermindAi } from './ai' import { signInToNevermind, getNevermindAuth, signOutFromNevermind } from './nevermind-auth' +import { currentNevermindCompatibilityManifest, onNevermindCompatibilityChanged, warmNevermindCompatibilityCache } from './nevermind-compatibility' import { initSentry } from './sentry' initSentry() @@ -1224,6 +1225,31 @@ function patchUpdatesView() { }) } +function updateActionForCompatibilityPrompt(updateUrl?: string) { + const downloadedInfo = isNewerVersion(updateManager.state.downloadedInfo?.version) ? updateManager.state.downloadedInfo : null + const availableInfo = isNewerVersion(updateManager.state.availableInfo?.version) ? updateManager.state.availableInfo : null + if (downloadedInfo) return { type: 'installUpdate', title: `Install Nevermind ${downloadedInfo.version || ''}`.trim() } + if (availableInfo) return { type: 'downloadUpdate', title: `Download Nevermind ${availableInfo.version || ''}`.trim() } + if (updateManager.canUseAutoUpdates()) return { type: 'checkForUpdates', title: 'Check for Update' } + return updateUrl ? { type: 'openUrl', title: 'Download Update', url: updateUrl } : undefined +} + +function compatibilityPromptAction() { + const manifest = currentNevermindCompatibilityManifest() + if (manifest?.client?.compatible !== false) return null + const version = manifest.desktop?.latestVersion || manifest.desktop?.minimumSupportedVersion || '' + const primaryAction = updateActionForCompatibilityPrompt(manifest.desktop?.updateUrl) + return { + id: 'updates:compatibility-required', + title: 'Update Nevermind', + subtitle: version ? `Nevermind ${version} or newer is required for backend compatibility` : 'This version is no longer supported by the backend', + icon: 'restart', + score: 1_100, + primaryAction, + actionPanel: primaryAction ? { sections: [{ actions: [primaryAction] }] } : undefined, + } +} + function updatePromptAction() { const downloadedInfo = isNewerVersion(updateManager.state.downloadedInfo?.version) ? updateManager.state.downloadedInfo : null const availableInfo = isNewerVersion(updateManager.state.availableInfo?.version) ? updateManager.state.availableInfo : null @@ -3132,7 +3158,7 @@ function createUpdatesExtension() { ...extension, commands: [{ ...checkItem(), run: () => checkForUpdatesView() }], rootItems() { - return [updatePromptAction() || checkItem()] + return [compatibilityPromptAction() || updatePromptAction() || checkItem()] }, } } @@ -5134,7 +5160,8 @@ app.whenReady().then(async () => { registerLocalFileProtocol() installPermissionHandlers(isDev) updateManager.configure() - updateManager.onStateChange(() => patchUpdatesView()) + updateManager.onStateChange(() => { patchUpdatesView(); invalidateExtensionRootItems() }) + onNevermindCompatibilityChanged(() => invalidateExtensionRootItems()) await loadUserState() registerHostJobs() @@ -5189,12 +5216,14 @@ app.whenReady().then(async () => { }) ipcMain.handle('nevermind:auth-status', async () => { const auth = await getNevermindAuth() + if (auth?.baseUrl) warmNevermindCompatibilityCache(auth.baseUrl) logInfo('nevermind.auth-status.check', { authed: Boolean(auth), email: auth?.email, userData: app.getPath('userData') }, { source: 'host', scope: 'nevermind' }) return auth ? { authed: true, email: auth.email } : { authed: false } }) ipcMain.handle('nevermind:sign-in', async () => { const result = await signInToNevermind() if (result.ok) { + warmNevermindCompatibilityCache(result.auth.baseUrl) invalidateExtensionRootItems() broadcastAuthChanged({ authed: true, email: result.auth.email }) return { ok: true, email: result.auth.email } diff --git a/src/electron/nevermind-compatibility.ts b/src/electron/nevermind-compatibility.ts index b21594f..c4226b4 100644 --- a/src/electron/nevermind-compatibility.ts +++ b/src/electron/nevermind-compatibility.ts @@ -1,3 +1,6 @@ +import { app } from 'electron' +import fs from 'node:fs/promises' +import path from 'node:path' import * as logger from './logger' import { nevermindDesktopHeaders } from './nevermind-api' @@ -13,6 +16,23 @@ export type NevermindCompatibilityManifest = { } } +type CachedCompatibilityManifest = { + baseUrl: string + fetchedAt: string + manifest: NevermindCompatibilityManifest +} + +type CompatibilityCacheFile = { + manifests?: Record +} + +type CompatibilityListener = () => void + +const CACHE_FILENAME = 'nevermind-compatibility.json' +const cachedManifests = new Map() +const listeners = new Set() +let cacheLoadPromise: Promise | null = null + export class NevermindCompatibilityError extends Error { updateUrl?: string minimumSupportedVersion?: string @@ -30,15 +50,46 @@ export class NevermindCompatibilityError extends Error { } } +export function onNevermindCompatibilityChanged(listener: CompatibilityListener) { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function currentNevermindCompatibilityManifest(baseUrl?: string) { + if (baseUrl) return cachedManifests.get(normalizeBaseUrl(baseUrl))?.manifest || null + return [...cachedManifests.values()] + .sort((left, right) => Date.parse(right.fetchedAt) - Date.parse(left.fetchedAt))[0] + ?.manifest || null +} + +export async function getCachedNevermindCompatibilityManifest(baseUrl: string) { + await loadCompatibilityCache() + return currentNevermindCompatibilityManifest(baseUrl) +} + +export function warmNevermindCompatibilityCache(baseUrl: string) { + void (async () => { + await loadCompatibilityCache() + notifyCompatibilityChanged() + await fetchCompatibilityManifest(baseUrl) + })().catch((error) => logger.warn('nevermind.compatibility.warm.failed', error as Error)) +} + export async function checkNevermindCompatibility(baseUrl: string) { - const trimmed = baseUrl.replace(/\/$/, '') - let res: Response - try { - res = await fetch(`${trimmed}/api/compatibility`, { headers: nevermindDesktopHeaders() }) - } catch (error) { + const cached = await getCachedNevermindCompatibilityManifest(baseUrl) + const manifest = await fetchCompatibilityManifest(baseUrl).catch((error) => { logger.warn('nevermind.compatibility.fetch.failed', error as Error) return null - } + }) + const effectiveManifest = manifest || cached + if (!effectiveManifest) return null + if (effectiveManifest.client?.compatible === false) throw new NevermindCompatibilityError(effectiveManifest) + return effectiveManifest +} + +async function fetchCompatibilityManifest(baseUrl: string) { + const trimmed = normalizeBaseUrl(baseUrl) + const res = await fetch(`${trimmed}/api/compatibility`, { headers: nevermindDesktopHeaders() }) if (res.status === 404) return null if (!res.ok) { logger.warn('nevermind.compatibility.unavailable', { status: res.status }) @@ -46,6 +97,51 @@ export async function checkNevermindCompatibility(baseUrl: string) { } const manifest = (await res.json().catch(() => null)) as NevermindCompatibilityManifest | null if (!manifest) return null - if (manifest.client?.compatible === false) throw new NevermindCompatibilityError(manifest) + await cacheCompatibilityManifest(trimmed, manifest) return manifest } + +async function cacheCompatibilityManifest(baseUrl: string, manifest: NevermindCompatibilityManifest) { + await loadCompatibilityCache() + cachedManifests.set(baseUrl, { baseUrl, fetchedAt: new Date().toISOString(), manifest }) + await saveCompatibilityCache() + notifyCompatibilityChanged() +} + +async function loadCompatibilityCache() { + if (cacheLoadPromise) return cacheLoadPromise + cacheLoadPromise = (async () => { + try { + const data = JSON.parse(await fs.readFile(compatibilityCachePath(), 'utf8')) as CompatibilityCacheFile + cachedManifests.clear() + for (const [baseUrl, entry] of Object.entries(data.manifests || {})) { + if (entry?.manifest) cachedManifests.set(normalizeBaseUrl(baseUrl), { ...entry, baseUrl: normalizeBaseUrl(entry.baseUrl || baseUrl) }) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') logger.warn('nevermind.compatibility.cache.read.failed', error as Error) + } + })() + return cacheLoadPromise +} + +async function saveCompatibilityCache() { + try { + await fs.writeFile(compatibilityCachePath(), JSON.stringify({ manifests: Object.fromEntries(cachedManifests) }, null, 2), { mode: 0o600 }) + } catch (error) { + logger.warn('nevermind.compatibility.cache.write.failed', error as Error) + } +} + +function compatibilityCachePath() { + return path.join(app.getPath('userData'), CACHE_FILENAME) +} + +function normalizeBaseUrl(baseUrl: string) { + return String(baseUrl || '').replace(/\/$/, '') +} + +function notifyCompatibilityChanged() { + for (const listener of listeners) { + try { listener() } catch {} + } +} diff --git a/src/extension-view.tsx b/src/extension-view.tsx index 0445be9..95abddc 100644 --- a/src/extension-view.tsx +++ b/src/extension-view.tsx @@ -98,7 +98,12 @@ function NevermindSignInGate({ onSignIn }: { onSignIn: () => void }) { } function NevermindLimitGate({ limit, runAction }: { limit: AiLimitState; runAction: (action: CommandAction) => void }) { - const action = limit.dashboardUrl ? { + const action = limit.action ? { + value: 'run-limit-action', + icon: , + title: limit.actionTitle || limit.action.title, + onSelect: () => runAction(limit.action!), + } : limit.dashboardUrl ? { value: 'open-dashboard', icon: , title: limit.actionTitle || 'Open Dashboard', diff --git a/src/use-ai-chat.ts b/src/use-ai-chat.ts index 47c7b1b..1a61f79 100644 --- a/src/use-ai-chat.ts +++ b/src/use-ai-chat.ts @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { CommandView } from './model' +import type { CommandAction, CommandView } from './model' -export type AiLimitState = { kind?: string; title: string; message: string; actionTitle?: string; dashboardUrl?: string } +export type AiLimitState = { kind?: string; title: string; message: string; actionTitle?: string; dashboardUrl?: string; action?: CommandAction } export type AiChatEvent = { type: string; text?: string; message?: string; name?: string; chatId?: string; label?: string; data?: unknown } function limitStateFromEvent(event: AiChatEvent): AiLimitState | null { From eaa8653e774f1e53b16288c3d519730b9f2caefa Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Sat, 6 Jun 2026 00:41:20 +0200 Subject: [PATCH 4/8] test: add backend route contract coverage --- .github/pull_request_template.md | 15 ++ backend/src/db/client.ts | 15 +- backend/src/lib/cost.ts | 5 +- backend/src/lib/env.ts | 4 + backend/src/lib/ratelimit.ts | 17 ++ backend/src/lib/upstream.ts | 10 +- backend/src/lib/workos.ts | 7 +- backend/src/pages/api/auth/signin.ts | 3 +- backend/src/pages/api/contract-routes.test.ts | 235 ++++++++++++++++++ package.json | 2 +- scripts/check-backend-contract-fixtures.cjs | 47 ++++ scripts/release.sh | 3 + src/docs/backend-api-compatibility.md | 16 +- 13 files changed, 365 insertions(+), 14 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 backend/src/lib/env.ts create mode 100644 backend/src/pages/api/contract-routes.test.ts create mode 100644 scripts/check-backend-contract-fixtures.cjs diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..bd79c16 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## Summary + + +## Verification + +- [ ] `mise exec -- pnpm test` + +## Backend API compatibility + +If this PR changes backend routes, proxy/auth/token/device flows, model descriptors, rate limits, billing errors, or desktop compatibility headers: + +- [ ] Updated route-level contract tests. +- [ ] Updated `backend/src/fixtures/contracts/` fixtures when supported desktop clients depend on the changed shape. +- [ ] Confirmed the change is additive, feature-gated, versioned, or has explicit unsupported-client update UX. + diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index d7494b9..08a3ff2 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -2,12 +2,13 @@ import { Pool, neonConfig } from '@neondatabase/serverless'; import * as Sentry from '@sentry/astro'; import { drizzle } from 'drizzle-orm/neon-serverless'; import ws from 'ws'; +import { env } from '../lib/env'; import { log } from '../lib/log'; import * as schema from './schema'; neonConfig.webSocketConstructor = ws; -const pool = new Pool({ connectionString: import.meta.env.DATABASE_URL }); +const pool = new Pool({ connectionString: env('DATABASE_URL') }); function isNeonAdministrativeTermination(error: unknown) { if (!(error instanceof Error)) return false; @@ -24,4 +25,14 @@ pool.on('error', (error: unknown) => { Sentry.captureException(error); }); -export const db = drizzle(pool, { schema }); +const productionDb = drizzle(pool, { schema }); + +export let db = productionDb; + +export function setDbForTests(nextDb: typeof productionDb) { + db = nextDb; +} + +export function resetDbForTests() { + db = productionDb; +} diff --git a/backend/src/lib/cost.ts b/backend/src/lib/cost.ts index 04f2218..ad166b8 100644 --- a/backend/src/lib/cost.ts +++ b/backend/src/lib/cost.ts @@ -1,9 +1,10 @@ export type { ModelCost } from './pricing'; export { lookupModelCost } from './pricing'; +import { env } from './env'; import type { ModelCost } from './pricing'; -export const CREDIT_USD = Number(import.meta.env.CREDIT_USD ?? 0.0001); -export const MARKUP = Number(import.meta.env.CREDIT_MARKUP ?? 1.2); +export const CREDIT_USD = Number(env('CREDIT_USD') ?? 0.0001); +export const MARKUP = Number(env('CREDIT_MARKUP') ?? 1.2); export function computeUsdCost(cost: ModelCost, inputTokens: number, outputTokens: number): number { return (inputTokens * cost.inputUsdPerMtok + outputTokens * cost.outputUsdPerMtok) / 1_000_000; diff --git a/backend/src/lib/env.ts b/backend/src/lib/env.ts new file mode 100644 index 0000000..6a8dcb4 --- /dev/null +++ b/backend/src/lib/env.ts @@ -0,0 +1,4 @@ +export function env(name: string): string | undefined { + const metaEnv = (import.meta as unknown as { env?: Record }).env; + return metaEnv?.[name] ?? process.env[name]; +} diff --git a/backend/src/lib/ratelimit.ts b/backend/src/lib/ratelimit.ts index 98390f1..56b79a9 100644 --- a/backend/src/lib/ratelimit.ts +++ b/backend/src/lib/ratelimit.ts @@ -29,6 +29,21 @@ const chatPerDayPaid = makeLimiter('chat:day:paid', 5000, '1 d'); export type RateLimitDecision = { ok: true } | { ok: false; retryAfterSec: number; scope: string }; +type RateLimitOverrides = { + chat?: typeof rateLimitChat; + ip?: typeof rateLimitIp; +}; + +let testOverrides: RateLimitOverrides = {}; + +export function setRateLimitOverridesForTests(overrides: RateLimitOverrides) { + testOverrides = overrides; +} + +export function resetRateLimitOverridesForTests() { + testOverrides = {}; +} + async function checkPair( key: string, scope: string, @@ -48,6 +63,7 @@ async function checkPair( } export async function rateLimitChat(userId: string, kind: 'free' | 'paid'): Promise { + if (testOverrides.chat) return testOverrides.chat(userId, kind); const [perMin, perDay] = kind === 'free' ? [chatPerMinFree, chatPerDayFree] : [chatPerMinPaid, chatPerDayPaid]; return checkPair(userId, `chat:${kind}`, perMin, perDay); } @@ -65,6 +81,7 @@ export async function rateLimitIp( limit = 30, window: `${number} ${'s' | 'm' | 'h' | 'd'}` = '1 m', ): Promise { + if (testOverrides.ip) return testOverrides.ip(scope, ip, limit, window); if (!ip) return { ok: true }; const limiter = ipLimiter(scope, limit, window); if (!limiter) return { ok: true }; diff --git a/backend/src/lib/upstream.ts b/backend/src/lib/upstream.ts index 927e440..74da65a 100644 --- a/backend/src/lib/upstream.ts +++ b/backend/src/lib/upstream.ts @@ -1,3 +1,5 @@ +import { env } from './env'; + export type UpstreamApi = 'openai-completions' | 'anthropic-messages' | 'google-generative-ai'; export class UpstreamConfigError extends Error {} @@ -11,13 +13,13 @@ export function selectApiForModel(provider: string, modelId: string): UpstreamAp export function getUpstreamConfig(provider: string): { baseUrl: string; apiKey: string } { if (provider === 'openrouter') { - const baseUrl = String(import.meta.env.OPENROUTER_BASE_URL ?? 'https://openrouter.ai/api/v1').replace(/\/$/, ''); - const apiKey = String(import.meta.env.OPENROUTER_API_KEY ?? ''); + const baseUrl = String(env('OPENROUTER_BASE_URL') ?? 'https://openrouter.ai/api/v1').replace(/\/$/, ''); + const apiKey = String(env('OPENROUTER_API_KEY') ?? ''); if (!apiKey) throw new UpstreamConfigError('Missing OPENROUTER_API_KEY'); return { baseUrl, apiKey }; } - const baseUrl = String(import.meta.env.OPENCODE_BASE_URL ?? 'https://opencode.ai/zen/v1').replace(/\/$/, ''); - const apiKey = String(import.meta.env.OPENCODE_API_KEY ?? ''); + const baseUrl = String(env('OPENCODE_BASE_URL') ?? 'https://opencode.ai/zen/v1').replace(/\/$/, ''); + const apiKey = String(env('OPENCODE_API_KEY') ?? ''); if (!apiKey) throw new UpstreamConfigError('Missing OPENCODE_API_KEY'); return { baseUrl, apiKey }; } diff --git a/backend/src/lib/workos.ts b/backend/src/lib/workos.ts index 5e74957..4a36178 100644 --- a/backend/src/lib/workos.ts +++ b/backend/src/lib/workos.ts @@ -1,10 +1,11 @@ import { WorkOS } from '@workos-inc/node'; +import { env } from './env'; -export const WORKOS_CLIENT_ID = import.meta.env.WORKOS_CLIENT_ID as string; -export const workos = new WorkOS(import.meta.env.WORKOS_API_KEY, { +export const WORKOS_CLIENT_ID = env('WORKOS_CLIENT_ID') as string; +export const workos = new WorkOS(env('WORKOS_API_KEY'), { clientId: WORKOS_CLIENT_ID, }); -export const COOKIE_PASSWORD = import.meta.env.WORKOS_COOKIE_PASSWORD as string; +export const COOKIE_PASSWORD = env('WORKOS_COOKIE_PASSWORD') as string; export const SESSION_COOKIE = 'nvm_session'; export async function getSessionFromCookies(cookieHeader: string | null) { diff --git a/backend/src/pages/api/auth/signin.ts b/backend/src/pages/api/auth/signin.ts index a1f82f1..db76d01 100644 --- a/backend/src/pages/api/auth/signin.ts +++ b/backend/src/pages/api/auth/signin.ts @@ -1,6 +1,7 @@ import type { APIRoute } from 'astro'; import { workos, WORKOS_CLIENT_ID } from '../../../lib/workos'; import { clientIp, rateLimitIp, tooManyRequests } from '../../../lib/ratelimit'; +import { env } from '../../../lib/env'; export const GET: APIRoute = async ({ url, request, redirect }) => { const decision = await rateLimitIp('auth', clientIp(request), 30, '1 m'); @@ -9,7 +10,7 @@ export const GET: APIRoute = async ({ url, request, redirect }) => { const authorizationUrl = workos.userManagement.getAuthorizationUrl({ provider: 'authkit', clientId: WORKOS_CLIENT_ID, - redirectUri: import.meta.env.WORKOS_REDIRECT_URI, + redirectUri: env('WORKOS_REDIRECT_URI'), state: returnTo, }); return redirect(authorizationUrl); diff --git a/backend/src/pages/api/contract-routes.test.ts b/backend/src/pages/api/contract-routes.test.ts new file mode 100644 index 0000000..6bfde05 --- /dev/null +++ b/backend/src/pages/api/contract-routes.test.ts @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { setDbForTests, resetDbForTests } from '../../db/client'; +import { resetRateLimitOverridesForTests, setRateLimitOverridesForTests } from '../../lib/ratelimit'; +import { POST as initiateDeviceAuth } from './auth/device/initiate'; +import { POST as exchangeDeviceAuth } from './auth/device/exchange'; +import { GET as getActiveModel } from './v1/active-model'; +import { POST as postChatCompletion } from './v1/chat/completions'; + +type FakeDb = ReturnType; + +const originalFetch = globalThis.fetch; + +function createChain(result: unknown, onValues?: (values: unknown) => void) { + const promise = () => Promise.resolve(result); + const chain = { + from: () => chain, + innerJoin: () => chain, + where: () => chain, + limit: () => promise(), + set: () => chain, + values: (values: unknown) => { + onValues?.(values); + return chain; + }, + returning: () => promise(), + then: (resolve: Parameters['then']>[0], reject: Parameters['then']>[1]) => promise().then(resolve, reject), + catch: (reject: Parameters['catch']>[0]) => promise().catch(reject), + }; + return chain; +} + +function createFakeDb(input: { selects?: unknown[]; inserts?: unknown[]; updates?: unknown[] } = {}) { + const selects = [...(input.selects ?? [])]; + const inserts = [...(input.inserts ?? [])]; + const updates = [...(input.updates ?? [])]; + const insertedValues: unknown[] = []; + const db = { + insertedValues, + select: () => createChain(selects.shift() ?? []), + insert: () => createChain(inserts.shift() ?? [], (values) => insertedValues.push(values)), + update: () => createChain(updates.shift() ?? []), + transaction: async (callback: (tx: FakeDb) => Promise) => callback(db as FakeDb), + }; + return db; +} + +function routeContext(request: Request, url = new URL(request.url)) { + return { request, url } as any; +} + +function installDb(db: FakeDb) { + setDbForTests(db as any); + return db; +} + +function installModelsDevFetch() { + globalThis.fetch = async (input: string | URL | Request) => { + const url = String(input instanceof Request ? input.url : input); + if (url === 'https://models.dev/api.json') { + return Response.json({ + opencode: { + models: { + 'gemini-3-flash': { + id: 'gemini-3-flash', + name: 'Gemini 3 Flash', + cost: { input: 0.3, output: 2.5 }, + limit: { context: 100000, output: 8192 }, + modalities: { input: ['text'] }, + }, + }, + }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }; +} + +function proxySelects(options: { free?: number; paid?: number; model?: string | null } = {}) { + return [ + [{ user: { id: 'user_1', email: 'pablo@example.com', role: 'user' }, tokenId: 'token_1' }], + [{ free: options.free ?? 10, paid: options.paid ?? 0 }], + [], + options.model === null ? [] : [{ value: options.model ?? 'gemini-3-flash' }], + ]; +} + +function authorizedChatRequest(body: unknown = { model: 'placeholder', messages: [{ role: 'user', content: 'hello' }] }) { + return new Request('https://api.nvm.fyi/api/v1/chat/completions', { + method: 'POST', + headers: { + authorization: 'Bearer nvm_pat_test', + 'content-type': 'application/json', + 'x-nevermind-client': 'desktop', + 'x-nevermind-client-version': '0.6.2', + 'x-nevermind-api-version': '1', + }, + body: JSON.stringify(body), + }); +} + +afterEach(() => { + resetDbForTests(); + resetRateLimitOverridesForTests(); + globalThis.fetch = originalFetch; + delete process.env.OPENCODE_API_KEY; + delete process.env.OPENCODE_BASE_URL; +}); + +test('device auth initiate returns the desktop-v1 initiation contract', async () => { + const db = installDb(createFakeDb()); + const request = new Request('https://api.nvm.fyi/api/auth/device/initiate', { + method: 'POST', + body: JSON.stringify({ label: ' Pablo Mac ' }), + }); + + const response = await initiateDeviceAuth(routeContext(request)); + const body = await response.json() as any; + + assert.equal(response.status, 200); + assert.equal(typeof body.code, 'string'); + assert.equal(body.verifyUrl, `https://api.nvm.fyi/auth/device?code=${body.code}`); + assert.match(body.expiresAt, /^\d{4}-\d{2}-\d{2}T/); + assert.equal(body.pollIntervalMs, 2000); + assert.equal((db.insertedValues[0] as any).deviceLabel, 'Pablo Mac'); +}); + +test('device auth exchange returns pending and missing-code contracts', async () => { + const missingCode = await exchangeDeviceAuth(routeContext(new Request('https://api.nvm.fyi/api/auth/device/exchange', { method: 'POST', body: '{}' }))); + assert.equal(missingCode.status, 400); + assert.equal(await missingCode.text(), 'Missing code'); + + installDb(createFakeDb({ selects: [[{ code: 'device_code', approvedAt: null, consumedAt: null, userId: null }]] })); + const pending = await exchangeDeviceAuth(routeContext(new Request('https://api.nvm.fyi/api/auth/device/exchange', { + method: 'POST', + body: JSON.stringify({ code: 'device_code' }), + }))); + assert.equal(pending.status, 200); + assert.deepEqual(await pending.json(), { status: 'pending' }); +}); + +test('active-model route returns descriptor contract with compatibility headers', async () => { + installModelsDevFetch(); + installDb(createFakeDb({ selects: proxySelects() })); + const response = await getActiveModel(routeContext(new Request('https://api.nvm.fyi/api/v1/active-model', { + headers: { authorization: 'Bearer nvm_pat_test', 'x-request-id': 'req_active_model' }, + }))); + const body = await response.json() as any; + + assert.equal(response.status, 200); + assert.equal(response.headers.get('x-request-id'), 'req_active_model'); + assert.ok(response.headers.get('x-nevermind-backend-version')); + assert.equal(body.id, 'gemini-3-flash'); + assert.equal(body.name, 'Gemini 3 Flash'); + assert.equal(body.provider, 'nevermind'); + assert.equal(body.api, 'google-generative-ai'); + assert.equal(body.baseUrl, 'https://api.nvm.fyi/api/v1'); +}); + +test('proxy route returns stable auth, credits, model config, and prompt-size errors', async () => { + const unauthorized = await postChatCompletion(routeContext(new Request('https://api.nvm.fyi/api/v1/chat/completions', { method: 'POST' }))); + assert.equal(unauthorized.status, 401); + assert.equal(await unauthorized.text(), 'Unauthorized'); + assert.ok(unauthorized.headers.get('x-request-id')); + + installDb(createFakeDb({ selects: proxySelects({ free: 0, paid: 0 }) })); + const noCredits = await postChatCompletion(routeContext(authorizedChatRequest())); + assert.equal(noCredits.status, 402); + assert.deepEqual(await noCredits.json(), { + error: { type: 'insufficient_credits', message: 'No credits remaining', dashboard_url: 'https://nvm.fyi/dashboard' }, + }); + + installDb(createFakeDb({ selects: proxySelects({ model: null }) })); + const noModel = await postChatCompletion(routeContext(authorizedChatRequest())); + assert.equal(noModel.status, 503); + assert.deepEqual(await noModel.json(), { + error: { type: 'model_not_configured', message: 'No active model configured. Admin must set one.' }, + }); + + installModelsDevFetch(); + process.env.OPENCODE_API_KEY = 'upstream-key'; + installDb(createFakeDb({ selects: proxySelects({ free: 1000000 }) })); + const promptTooLarge = await postChatCompletion(routeContext(authorizedChatRequest({ messages: [{ role: 'user', content: 'x'.repeat(400_004) }] }))); + assert.equal(promptTooLarge.status, 413); + assert.deepEqual(await promptTooLarge.json(), { + error: { type: 'prompt_too_large', message: 'Prompt exceeds 100000 input tokens' }, + }); +}); + +test('proxy route returns the rate-limit contract', async () => { + installModelsDevFetch(); + process.env.OPENCODE_API_KEY = 'upstream-key'; + setRateLimitOverridesForTests({ chat: async () => ({ ok: false, scope: 'chat:free:minute', retryAfterSec: 7 }) }); + installDb(createFakeDb({ selects: proxySelects() })); + + const response = await postChatCompletion(routeContext(authorizedChatRequest())); + assert.equal(response.status, 429); + assert.equal(response.headers.get('Retry-After'), '7'); + assert.deepEqual(await response.json(), { + error: { type: 'rate_limited', message: 'Rate limit exceeded (chat:free:minute)', retry_after: 7, dashboard_url: 'https://nvm.fyi/dashboard' }, + }); +}); + +test('proxy route preserves streaming responses and records stream usage', async () => { + installModelsDevFetch(); + process.env.OPENCODE_API_KEY = 'upstream-key'; + process.env.OPENCODE_BASE_URL = 'https://upstream.example/v1'; + const db = installDb(createFakeDb({ selects: proxySelects({ free: 1000 }) })); + let forwardedBody = ''; + globalThis.fetch = async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (url === 'https://models.dev/api.json') { + return Response.json({ opencode: { models: { 'gemini-3-flash': { id: 'gemini-3-flash', cost: { input: 0.3, output: 2.5 } } } } }); + } + assert.equal(url, 'https://upstream.example/v1/chat/completions'); + assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer upstream-key'); + forwardedBody = String(init?.body); + return new Response('data: {"usage":{"prompt_tokens":2,"completion_tokens":3}}\n\ndata: [DONE]\n\n', { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + }; + + const response = await postChatCompletion(routeContext(authorizedChatRequest())); + const text = await response.text(); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + assert.match(response.headers.get('x-request-id') || '', /^[0-9a-f-]{36}$/); + assert.equal(text, 'data: {"usage":{"prompt_tokens":2,"completion_tokens":3}}\n\ndata: [DONE]\n\n'); + assert.equal(JSON.parse(forwardedBody).model, 'gemini-3-flash'); + assert.equal(db.insertedValues.length, 2); + assert.equal((db.insertedValues.at(-1) as any).inputTokens, 2); + assert.equal((db.insertedValues.at(-1) as any).outputTokens, 3); +}); diff --git a/package.json b/package.json index 6a168a8..f806f7c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev": "node ./scripts/electron-dev.cjs", "build": "electron-vite build", "typecheck": "tsc -p tsconfig.check.json", - "test": "pnpm build && node --check dist/main/main.js && node --check dist/preload/preload.cjs && node scripts/check-design-system.cjs && node scripts/check-internal-extensions.cjs && node scripts/check-clone-safe-actions.cjs && node scripts/check-packaged-resources.cjs && pnpm typecheck && pnpm -C backend test", + "test": "pnpm build && node --check dist/main/main.js && node --check dist/preload/preload.cjs && node scripts/check-design-system.cjs && node scripts/check-internal-extensions.cjs && node scripts/check-clone-safe-actions.cjs && node scripts/check-packaged-resources.cjs && node scripts/check-backend-contract-fixtures.cjs && pnpm typecheck && pnpm -C backend test", "palette:debug": "node .agents/skills/palette-debug/palette-debug.cjs", "learnings:export": "node scripts/learnings-export.cjs", "logs:tail": "node scripts/tail-log.cjs", diff --git a/scripts/check-backend-contract-fixtures.cjs b/scripts/check-backend-contract-fixtures.cjs new file mode 100644 index 0000000..6d8959d --- /dev/null +++ b/scripts/check-backend-contract-fixtures.cjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.join(process.cwd(), 'backend/src/fixtures/contracts'); + +function walk(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + return entry.isDirectory() ? walk(full) : [full]; + }); +} + +const files = walk(root).filter((file) => file.endsWith('.json')); +if (files.length === 0) { + console.error('No backend contract fixtures found under backend/src/fixtures/contracts'); + process.exit(1); +} + +for (const file of files) { + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (error) { + console.error(`Invalid JSON fixture: ${path.relative(process.cwd(), file)}`); + console.error(error); + process.exit(1); + } + + const relative = path.relative(process.cwd(), file); + if (relative.endsWith('compatibility-manifest.json')) { + for (const key of ['backend', 'api', 'desktop', 'client', 'features', 'notices']) { + if (!(key in parsed)) { + console.error(`Compatibility manifest fixture missing ${key}: ${relative}`); + process.exit(1); + } + } + } + + if (relative.endsWith('-error.json') && !parsed.error?.type) { + console.error(`Error fixture missing error.type: ${relative}`); + process.exit(1); + } +} + +console.log(`Backend contract fixture checks passed (${files.length} fixtures)`); diff --git a/scripts/release.sh b/scripts/release.sh index 7a4cc75..e171065 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -22,6 +22,9 @@ if [[ "$current_version" != "$VERSION" ]]; then git commit -m "release: $TAG" fi +echo "Before tagging $TAG, confirm backend contract fixtures are current for desktop-used API shapes." +echo "See src/docs/backend-api-compatibility.md#backend-contract-prrelease-checklist." + if git rev-parse "$TAG" >/dev/null 2>&1; then echo "Tag $TAG already exists locally; reusing it." else diff --git a/src/docs/backend-api-compatibility.md b/src/docs/backend-api-compatibility.md index 8a425ba..568de8f 100644 --- a/src/docs/backend-api-compatibility.md +++ b/src/docs/backend-api-compatibility.md @@ -55,7 +55,21 @@ Before retiring an API major, the backend owner must document the sunset date, v Every backend route used by desktop is part of the product contract. Contract tests should cover successful responses, error shapes, auth/device flows, active-model descriptors, proxy routes, streaming semantics, rate limits, and unsupported-client responses. -When a desktop release is tagged, preserve the request/response expectations needed to keep that release supported through the support window. +Store stable JSON fixtures under `backend/src/fixtures/contracts//`. Fixtures should represent full response contracts that a supported desktop release depends on, including compatibility manifests and machine-readable error envelopes. Route-level tests may use builders/mocks for volatile fields such as request IDs, random device codes, and streaming bodies, but the response shape must stay stable. + +CI runs `scripts/check-backend-contract-fixtures.cjs` and `pnpm -C backend test` as part of the root `pnpm test` command. Backend route changes are not ready until fixture validation and route contract tests pass. + +When a desktop release is tagged, review the current desktop-used backend routes and add or refresh fixtures for any newly depended-on shape before the tag is pushed. The release commit should state whether contract fixtures changed or why no fixture update was needed. + +## Backend contract PR/release checklist + +For any PR touching `backend/src/pages/api`, `backend/src/lib/proxy.ts`, auth/token/device flows, model descriptors, billing/rate-limit responses, or desktop compatibility headers: + +- Add or update route-level contract tests for changed success and error shapes. +- Add or update JSON fixtures under `backend/src/fixtures/contracts/` when a supported desktop release depends on the shape. +- Verify `mise exec -- pnpm test` locally. +- If tagging a desktop release, note in the release commit whether contract fixtures changed. +- If a supported client cannot be kept compatible, use the API-major or unsupported-client process below. ## Breaking-change checklist From 88708cff73553c60fef7300e3ea0eeac61169a55 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Sat, 6 Jun 2026 00:45:12 +0200 Subject: [PATCH 5/8] feat: gate backend compatibility features --- backend/.env.example | 7 ++-- .../desktop-v1/compatibility-manifest.json | 2 ++ backend/src/lib/compatibility.test.ts | 16 ++++++++- backend/src/lib/compatibility.ts | 35 ++++++++++++++++--- backend/src/lib/proxy.ts | 4 ++- backend/src/pages/api/auth/device/exchange.ts | 3 ++ backend/src/pages/api/auth/device/initiate.ts | 3 ++ backend/src/pages/api/contract-routes.test.ts | 27 ++++++++++++++ src/docs/backend-api-compatibility.md | 4 ++- src/electron/ai.ts | 5 +-- src/electron/nevermind-compatibility.ts | 16 +++++++++ 11 files changed, 111 insertions(+), 11 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 41d849e..b6d9b02 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,6 +22,9 @@ CRON_SECRET= NEVERMIND_MIN_DESKTOP_VERSION=0.0.0 NEVERMIND_LATEST_DESKTOP_VERSION= NEVERMIND_DESKTOP_UPDATE_URL=https://github.com/pablopunk/nvm/releases/latest -# Comma list: feature_a,feature_b or JSON rules: -# {"feature_a":true,"feature_b":{"minDesktopVersion":"0.7.0","rolloutPercent":25}} +# Defaults include active_model_descriptor and proxy_streaming. +# Add comma-list flags or override with JSON rules: +# {"feature_a":true,"active_model_descriptor":{"minDesktopVersion":"0.7.0"},"proxy_streaming":{"rolloutPercent":25}} NEVERMIND_FEATURE_FLAGS= +# Comma list or JSON booleans, e.g. ai_proxy,ai_streaming,auth_device or {"ai_proxy":true} +NEVERMIND_KILL_SWITCHES= diff --git a/backend/src/fixtures/contracts/desktop-v1/compatibility-manifest.json b/backend/src/fixtures/contracts/desktop-v1/compatibility-manifest.json index d957a0b..065d0ad 100644 --- a/backend/src/fixtures/contracts/desktop-v1/compatibility-manifest.json +++ b/backend/src/fixtures/contracts/desktop-v1/compatibility-manifest.json @@ -23,6 +23,8 @@ "unsupportedReason": null }, "features": { + "active_model_descriptor": true, + "proxy_streaming": true, "streaming_v2": true }, "notices": [] diff --git a/backend/src/lib/compatibility.test.ts b/backend/src/lib/compatibility.test.ts index c916df0..f6c8fcb 100644 --- a/backend/src/lib/compatibility.test.ts +++ b/backend/src/lib/compatibility.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { afterEach, test } from 'node:test'; import { + backendKillSwitchEnabled, compareVersions, compatibilityError, compatibilityFeaturesForClient, @@ -15,6 +16,7 @@ afterEach(() => { delete process.env.NEVERMIND_LATEST_DESKTOP_VERSION; delete process.env.NEVERMIND_DESKTOP_UPDATE_URL; delete process.env.NEVERMIND_FEATURE_FLAGS; + delete process.env.NEVERMIND_KILL_SWITCHES; delete process.env.VERCEL_GIT_COMMIT_SHA; delete process.env.VERCEL_ENV; }); @@ -74,7 +76,7 @@ test('returns comma-list feature flags in the manifest', () => { const manifest = compatibilityManifestForRequest(request, { requestId: 'req_flags' }); - assert.deepEqual(manifest.features, { new_models: true, streaming_v2: true }); + assert.deepEqual(manifest.features, { active_model_descriptor: true, proxy_streaming: true, new_models: true, streaming_v2: true }); }); test('evaluates version, user, plan, and rollout feature rules', () => { @@ -89,6 +91,8 @@ test('evaluates version, user, plan, and rollout feature rules', () => { }); assert.deepEqual(compatibilityFeaturesForClient(client, { userId: 'user_1', plan: 'pro' }), { + active_model_descriptor: true, + proxy_streaming: true, enabled: true, needs_newer_desktop: false, allowed_user_plan: true, @@ -98,6 +102,16 @@ test('evaluates version, user, plan, and rollout feature rules', () => { }); }); +test('evaluates backend kill switches from comma-list and JSON config', () => { + process.env.NEVERMIND_KILL_SWITCHES = 'ai_proxy,auth_device'; + assert.equal(backendKillSwitchEnabled('ai_proxy'), true); + assert.equal(backendKillSwitchEnabled('ai_streaming'), false); + + process.env.NEVERMIND_KILL_SWITCHES = JSON.stringify({ ai_streaming: true, ai_proxy: false }); + assert.equal(backendKillSwitchEnabled('ai_proxy'), false); + assert.equal(backendKillSwitchEnabled('ai_streaming'), true); +}); + test('matches the desktop-v1 compatibility manifest fixture', () => { process.env.VERCEL_GIT_COMMIT_SHA = 'abcdef1234567890'; process.env.VERCEL_ENV = 'preview'; diff --git a/backend/src/lib/compatibility.ts b/backend/src/lib/compatibility.ts index 3be0a95..7a65042 100644 --- a/backend/src/lib/compatibility.ts +++ b/backend/src/lib/compatibility.ts @@ -12,6 +12,11 @@ export type DesktopClient = { arch: string | null; }; +const DEFAULT_FEATURES: Record = { + active_model_descriptor: true, + proxy_streaming: true, +}; + type FeatureFlagRule = boolean | { enabled?: boolean; minDesktopVersion?: string; @@ -130,6 +135,27 @@ export function compatibilityFeaturesForClient(client: DesktopClient, context: F return Object.fromEntries(Object.entries(definitions).map(([name, rule]) => [name, evaluateFeatureFlag(name, rule, client, context)])); } +export function backendKillSwitchEnabled(name: string) { + const raw = process.env.NEVERMIND_KILL_SWITCHES?.trim(); + if (!raw) return false; + if (!raw.startsWith('{')) return raw.split(',').map((value) => value.trim()).includes(name); + try { + const parsed = JSON.parse(raw) as Record; + return parsed?.[name] === true; + } catch (error) { + log.warn('kill_switches_parse_failed', { error }); + return false; + } +} + +export function killSwitchResponse(name: string, message: string, requestId?: string) { + log.warn('kill_switch_triggered', { kill_switch: name, request_id: requestId }); + return Response.json( + { error: { type: 'service_unavailable', message } }, + { status: 503, headers: compatibilityHeaders(requestId) }, + ); +} + export function unsupportedClientReason(client: DesktopClient) { if (client.apiVersion !== null && !SUPPORTED_API_VERSIONS.includes(client.apiVersion as 1)) return 'unsupported_api_version'; if (client.version && compareVersions(client.version, minimumSupportedDesktopVersion()) < 0) return 'unsupported_desktop_version'; @@ -165,16 +191,17 @@ export function compareVersions(left: string, right: string) { function featureFlagDefinitions(): Record { const raw = process.env.NEVERMIND_FEATURE_FLAGS?.trim(); - if (!raw) return {}; + if (!raw) return DEFAULT_FEATURES; if (!raw.startsWith('{')) { - return Object.fromEntries(raw.split(',').map((name) => [name.trim(), true]).filter(([name]) => Boolean(name))); + const envFeatures = Object.fromEntries(raw.split(',').map((name) => [name.trim(), true]).filter(([name]) => Boolean(name))); + return { ...DEFAULT_FEATURES, ...envFeatures }; } try { const parsed = JSON.parse(raw) as Record; - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? { ...DEFAULT_FEATURES, ...parsed } : DEFAULT_FEATURES; } catch (error) { log.warn('feature_flags_parse_failed', { error }); - return {}; + return DEFAULT_FEATURES; } } diff --git a/backend/src/lib/proxy.ts b/backend/src/lib/proxy.ts index 85be628..9cc5a53 100644 --- a/backend/src/lib/proxy.ts +++ b/backend/src/lib/proxy.ts @@ -19,7 +19,7 @@ import { getUpstreamConfig, UpstreamConfigError } from './upstream'; import { extractPatFromHeaders, getUserFromHeaders, type PatHeaderName } from './tokens'; import { rateLimitChat, tooManyRequests } from './ratelimit'; import { estimateInputTokensFromBody, estimatePromptCredits, MAX_INPUT_TOKENS } from './limits'; -import { backendVersion, desktopClientFromRequest, type DesktopClient } from './compatibility'; +import { backendKillSwitchEnabled, backendVersion, desktopClientFromRequest, killSwitchResponse, type DesktopClient } from './compatibility'; import { log } from './log'; import * as Sentry from '@sentry/astro'; @@ -222,6 +222,7 @@ function withRequestId(res: Response, requestId: string): Response { export async function proxyAndBill(cfg: ProxyConfig): Promise { const requestId = randomUUID(); + if (backendKillSwitchEnabled('ai_proxy')) return killSwitchResponse('ai_proxy', 'AI proxy is temporarily disabled.', requestId); const startedAt = Date.now(); const client = desktopClientFromRequest(cfg.request); Sentry.getCurrentScope().setTag('request_id', requestId); @@ -302,6 +303,7 @@ export async function proxyAndBill(cfg: ProxyConfig): Promise { } if (isStreamingContentType(upstreamResponse.headers.get('content-type'))) { + if (backendKillSwitchEnabled('ai_streaming')) return killSwitchResponse('ai_streaming', 'AI streaming is temporarily disabled.', requestId); const transformed = teeStreamAndBill(upstreamResponse, cfg, billCtx, upstreamResponse.status, startedAt); return new Response(transformed, { status: upstreamResponse.status, diff --git a/backend/src/pages/api/auth/device/exchange.ts b/backend/src/pages/api/auth/device/exchange.ts index 3ddaa00..4867a6b 100644 --- a/backend/src/pages/api/auth/device/exchange.ts +++ b/backend/src/pages/api/auth/device/exchange.ts @@ -3,9 +3,12 @@ import { and, eq, gt, isNull, isNotNull } from 'drizzle-orm'; import { db } from '../../../../db/client'; import { users, deviceCodes } from '../../../../db/schema'; import { createApiToken } from '../../../../lib/tokens'; +import { killSwitchResponse, backendKillSwitchEnabled, requestIdFromHeaders } from '../../../../lib/compatibility'; import { clientIp, rateLimitIp, tooManyRequests } from '../../../../lib/ratelimit'; export const POST: APIRoute = async ({ request }) => { + const requestId = requestIdFromHeaders(request.headers); + if (backendKillSwitchEnabled('auth_device')) return killSwitchResponse('auth_device', 'Device authorization is temporarily disabled.', requestId); const decision = await rateLimitIp('auth', clientIp(request), 60, '1 m'); if (!decision.ok) return tooManyRequests(decision); const body = (await request.json().catch(() => ({}))) as { code?: string }; diff --git a/backend/src/pages/api/auth/device/initiate.ts b/backend/src/pages/api/auth/device/initiate.ts index 03262b6..34a8e85 100644 --- a/backend/src/pages/api/auth/device/initiate.ts +++ b/backend/src/pages/api/auth/device/initiate.ts @@ -2,11 +2,14 @@ import type { APIRoute } from 'astro'; import { randomBytes } from 'node:crypto'; import { db } from '../../../../db/client'; import { deviceCodes } from '../../../../db/schema'; +import { killSwitchResponse, backendKillSwitchEnabled, requestIdFromHeaders } from '../../../../lib/compatibility'; import { clientIp, rateLimitIp, tooManyRequests } from '../../../../lib/ratelimit'; const TTL_MS = 5 * 60 * 1000; export const POST: APIRoute = async ({ request, url }) => { + const requestId = requestIdFromHeaders(request.headers); + if (backendKillSwitchEnabled('auth_device')) return killSwitchResponse('auth_device', 'Device authorization is temporarily disabled.', requestId); const decision = await rateLimitIp('auth', clientIp(request), 20, '1 m'); if (!decision.ok) return tooManyRequests(decision); const body = (await request.json().catch(() => ({}))) as { label?: string }; diff --git a/backend/src/pages/api/contract-routes.test.ts b/backend/src/pages/api/contract-routes.test.ts index 6bfde05..863f62b 100644 --- a/backend/src/pages/api/contract-routes.test.ts +++ b/backend/src/pages/api/contract-routes.test.ts @@ -105,6 +105,7 @@ afterEach(() => { globalThis.fetch = originalFetch; delete process.env.OPENCODE_API_KEY; delete process.env.OPENCODE_BASE_URL; + delete process.env.NEVERMIND_KILL_SWITCHES; }); test('device auth initiate returns the desktop-v1 initiation contract', async () => { @@ -125,6 +126,21 @@ test('device auth initiate returns the desktop-v1 initiation contract', async () assert.equal((db.insertedValues[0] as any).deviceLabel, 'Pablo Mac'); }); +test('device auth kill switch returns service-unavailable contract', async () => { + process.env.NEVERMIND_KILL_SWITCHES = 'auth_device'; + const response = await initiateDeviceAuth(routeContext(new Request('https://api.nvm.fyi/api/auth/device/initiate', { + method: 'POST', + headers: { 'x-request-id': 'req_auth_disabled' }, + body: '{}', + }))); + + assert.equal(response.status, 503); + assert.equal(response.headers.get('x-request-id'), 'req_auth_disabled'); + assert.deepEqual(await response.json(), { + error: { type: 'service_unavailable', message: 'Device authorization is temporarily disabled.' }, + }); +}); + test('device auth exchange returns pending and missing-code contracts', async () => { const missingCode = await exchangeDeviceAuth(routeContext(new Request('https://api.nvm.fyi/api/auth/device/exchange', { method: 'POST', body: '{}' }))); assert.equal(missingCode.status, 400); @@ -157,6 +173,17 @@ test('active-model route returns descriptor contract with compatibility headers' assert.equal(body.baseUrl, 'https://api.nvm.fyi/api/v1'); }); +test('proxy route kill switch returns service-unavailable contract', async () => { + process.env.NEVERMIND_KILL_SWITCHES = 'ai_proxy'; + const response = await postChatCompletion(routeContext(authorizedChatRequest())); + + assert.equal(response.status, 503); + assert.ok(response.headers.get('x-request-id')); + assert.deepEqual(await response.json(), { + error: { type: 'service_unavailable', message: 'AI proxy is temporarily disabled.' }, + }); +}); + test('proxy route returns stable auth, credits, model config, and prompt-size errors', async () => { const unauthorized = await postChatCompletion(routeContext(new Request('https://api.nvm.fyi/api/v1/chat/completions', { method: 'POST' }))); assert.equal(unauthorized.status, 401); diff --git a/src/docs/backend-api-compatibility.md b/src/docs/backend-api-compatibility.md index 568de8f..5cd8b33 100644 --- a/src/docs/backend-api-compatibility.md +++ b/src/docs/backend-api-compatibility.md @@ -41,7 +41,9 @@ New backend capabilities should be gated by explicit manifest features, not infe Feature flags may be returned by `GET /api/compatibility` in the `features` object. Backend configuration supports simple comma-list flags and JSON rules with desktop version, user, plan, and rollout constraints. Rollout percentages must be deterministic for a client/user so support can reason about why a user did or did not receive a feature. -Server-side kill switches should exist for risky behavior such as model provider changes, streaming transformations, billing enforcement changes, and auth flow changes. +Desktop must use `requireNevermindCompatibilityFeature` or `nevermindCompatibilityFeatureEnabled` before relying on new backend-advertised behavior. The backend currently advertises `active_model_descriptor` and `proxy_streaming` by default so desktop can gate dynamic model routing and future streaming behavior explicitly. + +Server-side kill switches should exist for risky behavior such as model provider changes, streaming transformations, billing enforcement changes, and auth flow changes. `NEVERMIND_KILL_SWITCHES` supports comma-list or JSON boolean switches for `ai_proxy`, `ai_streaming`, and `auth_device`. ## API-major breaking-change criteria diff --git a/src/electron/ai.ts b/src/electron/ai.ts index d9faedd..3eec465 100644 --- a/src/electron/ai.ts +++ b/src/electron/ai.ts @@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url' import ts from 'typescript' import * as logger from './logger' import { readRecentLogs, type LogLevel, type LogSource } from './logger' -import { checkNevermindCompatibility } from './nevermind-compatibility' +import { checkNevermindCompatibility, requireNevermindCompatibilityFeature } from './nevermind-compatibility' import type { CommandAction } from '../model' import { nevermindDesktopHeaders } from './nevermind-api' import { getNevermindAuth, NevermindAuthRequiredError } from './nevermind-auth' @@ -493,7 +493,8 @@ type BackendDescriptor = { async function fetchActiveModelDescriptor(baseUrl: string, token: string): Promise { const trimmed = baseUrl.replace(/\/$/, '') - await checkNevermindCompatibility(trimmed) + const manifest = await checkNevermindCompatibility(trimmed) + requireNevermindCompatibilityFeature('active_model_descriptor', manifest) const res = await fetch(`${trimmed}/api/v1/active-model`, { headers: nevermindDesktopHeaders({ Authorization: `Bearer ${token}` }), }) diff --git a/src/electron/nevermind-compatibility.ts b/src/electron/nevermind-compatibility.ts index c4226b4..dd25965 100644 --- a/src/electron/nevermind-compatibility.ts +++ b/src/electron/nevermind-compatibility.ts @@ -14,6 +14,7 @@ export type NevermindCompatibilityManifest = { latestVersion?: string | null updateUrl?: string } + features?: Record } type CachedCompatibilityManifest = { @@ -33,6 +34,13 @@ const cachedManifests = new Map() const listeners = new Set() let cacheLoadPromise: Promise | null = null +export class NevermindFeatureUnavailableError extends Error { + constructor(public feature: string) { + super(`Nevermind backend feature is unavailable: ${feature}`) + this.name = 'NevermindFeatureUnavailableError' + } +} + export class NevermindCompatibilityError extends Error { updateUrl?: string minimumSupportedVersion?: string @@ -67,6 +75,14 @@ export async function getCachedNevermindCompatibilityManifest(baseUrl: string) { return currentNevermindCompatibilityManifest(baseUrl) } +export function nevermindCompatibilityFeatureEnabled(feature: string, manifest = currentNevermindCompatibilityManifest()) { + return manifest?.features?.[feature] === true +} + +export function requireNevermindCompatibilityFeature(feature: string, manifest = currentNevermindCompatibilityManifest()) { + if (!nevermindCompatibilityFeatureEnabled(feature, manifest)) throw new NevermindFeatureUnavailableError(feature) +} + export function warmNevermindCompatibilityCache(baseUrl: string) { void (async () => { await loadCompatibilityCache() From 9d4773e481955ab2ea779267cafedbbe1bd175a9 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Sat, 6 Jun 2026 00:48:12 +0200 Subject: [PATCH 6/8] test: cover compatibility notice UI --- package.json | 2 +- src/extension-view.test.tsx | 45 +++++++++++++++++++++++++++++++++++++ src/extension-view.tsx | 4 ++-- src/ui.tsx | 2 +- 4 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 src/extension-view.test.tsx diff --git a/package.json b/package.json index f806f7c..2357621 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev": "node ./scripts/electron-dev.cjs", "build": "electron-vite build", "typecheck": "tsc -p tsconfig.check.json", - "test": "pnpm build && node --check dist/main/main.js && node --check dist/preload/preload.cjs && node scripts/check-design-system.cjs && node scripts/check-internal-extensions.cjs && node scripts/check-clone-safe-actions.cjs && node scripts/check-packaged-resources.cjs && node scripts/check-backend-contract-fixtures.cjs && pnpm typecheck && pnpm -C backend test", + "test": "pnpm build && node --check dist/main/main.js && node --check dist/preload/preload.cjs && node scripts/check-design-system.cjs && node scripts/check-internal-extensions.cjs && node scripts/check-clone-safe-actions.cjs && node scripts/check-packaged-resources.cjs && node scripts/check-backend-contract-fixtures.cjs && pnpm -C backend exec node --import tsx --test ../src/extension-view.test.tsx && pnpm typecheck && pnpm -C backend test", "palette:debug": "node .agents/skills/palette-debug/palette-debug.cjs", "learnings:export": "node scripts/learnings-export.cjs", "logs:tail": "node scripts/tail-log.cjs", diff --git a/src/extension-view.test.tsx b/src/extension-view.test.tsx new file mode 100644 index 0000000..1f7a98e --- /dev/null +++ b/src/extension-view.test.tsx @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { Command } from 'cmdk' +import { NevermindLimitGate } from './extension-view' +import type { CommandAction } from './model' + +test('renders unsupported-client update UI with structured updater action', () => { + const actions: CommandAction[] = [] + const html = renderToStaticMarkup( actions.push(action)} + />) + + assert.match(html, /role="status"/) + assert.match(html, /Update Nevermind/) + assert.match(html, /This version is no longer supported by the backend\./) + assert.match(html, /Check for Update/) + assert.doesNotMatch(html, /Open Dashboard/) +}) + +test('renders deprecation-warning UI with dashboard fallback action', () => { + const html = renderToStaticMarkup( {}} + />) + + assert.match(html, /role="status"/) + assert.match(html, /Backend API deprecation/) + assert.match(html, /This API contract will sunset soon\. Review the migration path\./) + assert.match(html, /Review migration/) +}) diff --git a/src/extension-view.tsx b/src/extension-view.tsx index 95abddc..f4e44d0 100644 --- a/src/extension-view.tsx +++ b/src/extension-view.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import React, { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' import { CornerDownLeft, CreditCard, LogIn, Search, Square } from 'lucide-react' import { actionsFromPanel, type CommandAction, type CommandItem, type CommandView } from './model' import type { AiLimitState } from './use-ai-chat' @@ -97,7 +97,7 @@ function NevermindSignInGate({ onSignIn }: { onSignIn: () => void }) { return } title="Sign in to Nevermind" subtitle="Connect this device to your Nevermind account to use AI chats." action={{ value: 'sign-in', icon: , title: busy ? 'Opening browser…' : 'Sign in to Nevermind', onSelect: handle }} /> } -function NevermindLimitGate({ limit, runAction }: { limit: AiLimitState; runAction: (action: CommandAction) => void }) { +export function NevermindLimitGate({ limit, runAction }: { limit: AiLimitState; runAction: (action: CommandAction) => void }) { const action = limit.action ? { value: 'run-limit-action', icon: , diff --git a/src/ui.tsx b/src/ui.tsx index 07dff0b..6587d8f 100644 --- a/src/ui.tsx +++ b/src/ui.tsx @@ -1,4 +1,4 @@ -import { type ReactNode } from 'react' +import React, { type ReactNode } from 'react' import { Command } from 'cmdk' import { Folder } from 'lucide-react' import type { CommandImage } from './model' From 1a140f630faacfa5f9dc15b4eedb7f0d94bb95eb Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Sat, 6 Jun 2026 00:50:19 +0200 Subject: [PATCH 7/8] docs: add backend api sunset guard --- backend/src/lib/compatibility.ts | 16 +++++++++++ package.json | 2 +- scripts/check-backend-api-major.cjs | 25 ++++++++++++++++ src/docs/backend-api-compatibility.md | 5 +++- src/docs/backend-api-sunset-template.md | 38 +++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 scripts/check-backend-api-major.cjs create mode 100644 src/docs/backend-api-sunset-template.md diff --git a/backend/src/lib/compatibility.ts b/backend/src/lib/compatibility.ts index 7a65042..b3e98ad 100644 --- a/backend/src/lib/compatibility.ts +++ b/backend/src/lib/compatibility.ts @@ -97,6 +97,7 @@ export function compatibilityManifestForRequest(request: Request, context: Featu const client = desktopClientFromRequest(request); const unsupportedReason = unsupportedClientReason(client); const features = compatibilityFeaturesForClient(client, context); + logDesktopClientSeen(client, context, !unsupportedReason); logFeatureEvaluations(features, client, context); return { backend: { @@ -164,6 +165,8 @@ export function unsupportedClientReason(client: DesktopClient) { export function compatibilityError(request: Request, message = 'This version of Nevermind is no longer supported.') { const requestId = requestIdFromHeaders(request.headers); + const client = desktopClientFromRequest(request); + logDesktopClientSeen(client, { requestId, route: new URL(request.url).pathname }, false); return Response.json( { error: { @@ -222,6 +225,19 @@ function rolloutBucket(name: string, client: DesktopClient, context: FeatureFlag return Number.parseInt(hex, 16) % 100; } +function logDesktopClientSeen(client: DesktopClient, context: FeatureFlagContext, compatible: boolean) { + log.info('desktop_client_seen', { + request_id: context.requestId || undefined, + route: context.route || 'compatibility', + client_name: client.name, + client_version: client.version, + client_api_version: client.apiVersion, + client_platform: client.platform, + client_arch: client.arch, + compatible, + }); +} + function logFeatureEvaluations(features: Record, client: DesktopClient, context: FeatureFlagContext) { if (!Object.keys(features).length) return; log.info('feature_flags_evaluated', { diff --git a/package.json b/package.json index 2357621..e96f9cb 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev": "node ./scripts/electron-dev.cjs", "build": "electron-vite build", "typecheck": "tsc -p tsconfig.check.json", - "test": "pnpm build && node --check dist/main/main.js && node --check dist/preload/preload.cjs && node scripts/check-design-system.cjs && node scripts/check-internal-extensions.cjs && node scripts/check-clone-safe-actions.cjs && node scripts/check-packaged-resources.cjs && node scripts/check-backend-contract-fixtures.cjs && pnpm -C backend exec node --import tsx --test ../src/extension-view.test.tsx && pnpm typecheck && pnpm -C backend test", + "test": "pnpm build && node --check dist/main/main.js && node --check dist/preload/preload.cjs && node scripts/check-design-system.cjs && node scripts/check-internal-extensions.cjs && node scripts/check-clone-safe-actions.cjs && node scripts/check-packaged-resources.cjs && node scripts/check-backend-contract-fixtures.cjs && node scripts/check-backend-api-major.cjs && pnpm -C backend exec node --import tsx --test ../src/extension-view.test.tsx && pnpm typecheck && pnpm -C backend test", "palette:debug": "node .agents/skills/palette-debug/palette-debug.cjs", "learnings:export": "node scripts/learnings-export.cjs", "logs:tail": "node scripts/tail-log.cjs", diff --git a/scripts/check-backend-api-major.cjs b/scripts/check-backend-api-major.cjs new file mode 100644 index 0000000..93a7eed --- /dev/null +++ b/scripts/check-backend-api-major.cjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); + +const apiV2Dir = path.join(process.cwd(), 'backend/src/pages/api/v2'); +if (!fs.existsSync(apiV2Dir)) { + console.log('Backend API major check passed (no /api/v2 routes present)'); + process.exit(0); +} + +const migrationDoc = path.join(process.cwd(), 'src/docs/backend-api-v2.md'); +if (!fs.existsSync(migrationDoc)) { + console.error('/api/v2 routes require src/docs/backend-api-v2.md with breaking-change and sunset details.'); + process.exit(1); +} + +const text = fs.readFileSync(migrationDoc, 'utf8').toLowerCase(); +for (const required of ['breaking change', 'sunset', 'client count', 'update message']) { + if (!text.includes(required)) { + console.error(`src/docs/backend-api-v2.md must mention: ${required}`); + process.exit(1); + } +} + +console.log('Backend API major check passed (/api/v2 documented)'); diff --git a/src/docs/backend-api-compatibility.md b/src/docs/backend-api-compatibility.md index 5cd8b33..31b7c64 100644 --- a/src/docs/backend-api-compatibility.md +++ b/src/docs/backend-api-compatibility.md @@ -51,7 +51,9 @@ Create a new API major, such as `/api/v2`, only when a backend change cannot be A change is API-major breaking when it removes or renames a field used by a supported desktop release, changes an error type/status that desktop handles specially, changes auth or billing semantics, changes model descriptor/provider routing in a way old clients cannot understand, changes streaming framing or termination semantics, or requires desktop to send a new non-optional request field/header. -Before retiring an API major, the backend owner must document the sunset date, verify active client counts are below the supported-client threshold, preserve user-visible update messaging, and keep compatibility errors available until the final sunset window closes. +Before retiring an API major, the backend owner must document the sunset date, verify active client counts are below the supported-client threshold, preserve user-visible update messaging, and keep compatibility errors available until the final sunset window closes. Use `src/docs/backend-api-sunset-template.md` for this record. + +CI runs `scripts/check-backend-api-major.cjs`, which allows no `/api/v2` routes by default and requires a dedicated `src/docs/backend-api-v2.md` migration/sunset document if a real `/api/v2` route is introduced. ## Contract testing @@ -72,6 +74,7 @@ For any PR touching `backend/src/pages/api`, `backend/src/lib/proxy.ts`, auth/to - Verify `mise exec -- pnpm test` locally. - If tagging a desktop release, note in the release commit whether contract fixtures changed. - If a supported client cannot be kept compatible, use the API-major or unsupported-client process below. +- Before sunsetting an API major, query `desktop_client_seen` logs for active clients by `client_api_version` and `client_version`. ## Breaking-change checklist diff --git a/src/docs/backend-api-sunset-template.md b/src/docs/backend-api-sunset-template.md new file mode 100644 index 0000000..6ad11fe --- /dev/null +++ b/src/docs/backend-api-sunset-template.md @@ -0,0 +1,38 @@ +# Backend API sunset template + +Use this document when a real breaking change requires a new backend API major such as `/api/v2`. + +## Breaking change + +- Current API major: +- New API major: +- Breaking change summary: +- Why additive fields, feature flags, or compatibility shims are insufficient: + +## Client count evidence + +Before setting a sunset date, query `desktop_client_seen` logs for the last 30 and 90 days and record active clients by desktop version and requested API major. + +- Log event: `desktop_client_seen` +- Required fields: `client_version`, `client_api_version`, `client_platform`, `client_arch`, `compatible` +- 30-day active clients on old major: +- 90-day active clients on old major: +- Supported desktop versions still on old major: + +## Sunset + +- First deprecation notice date: +- Forced-update date: +- Final backend removal date: +- Owner: +- Rollback plan: + +## User-visible update message + +Title: Update Nevermind + +Message: This version of Nevermind uses an older backend API that will stop working on . Install the latest Nevermind to keep using AI features. + +Primary action: Check for Update + +Fallback URL: https://github.com/pablopunk/nvm/releases/latest From aa6a64e372d1cbe8a4ab5328c73b506b761ae04d Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Sat, 6 Jun 2026 01:12:42 +0200 Subject: [PATCH 8/8] fix: address compatibility review comments --- backend/src/lib/compatibility.test.ts | 1 + backend/src/lib/compatibility.ts | 6 +++--- backend/src/pages/api/compatibility.ts | 2 +- backend/src/pages/api/contract-routes.test.ts | 6 ++++-- package.json | 1 - pnpm-lock.yaml | 3 --- src/electron/ai.ts | 6 +----- src/electron/main.ts | 21 +++++++++++++++++-- src/electron/nevermind-api.ts | 2 +- src/electron/nevermind-compatibility.ts | 10 ++++++++- src/use-ai-chat.ts | 10 ++++++++- 11 files changed, 48 insertions(+), 20 deletions(-) diff --git a/backend/src/lib/compatibility.test.ts b/backend/src/lib/compatibility.test.ts index f6c8fcb..9a0fede 100644 --- a/backend/src/lib/compatibility.test.ts +++ b/backend/src/lib/compatibility.test.ts @@ -60,6 +60,7 @@ test('detects unsupported desktop versions and API versions', () => { assert.equal(unsupportedClientReason({ name: 'desktop', version: '0.5.9', apiVersion: 1, platform: 'darwin', arch: 'arm64' }), 'unsupported_desktop_version'); assert.equal(unsupportedClientReason({ name: 'desktop', version: '0.6.0', apiVersion: 2, platform: 'darwin', arch: 'arm64' }), 'unsupported_api_version'); + assert.equal(unsupportedClientReason({ name: 'desktop', version: '0.6.0', apiVersion: 0, platform: 'darwin', arch: 'arm64' }), 'unsupported_api_version'); }); test('compares semver-like desktop versions', () => { diff --git a/backend/src/lib/compatibility.ts b/backend/src/lib/compatibility.ts index b3e98ad..9293d34 100644 --- a/backend/src/lib/compatibility.ts +++ b/backend/src/lib/compatibility.ts @@ -87,7 +87,7 @@ export function desktopClientFromRequest(request: Request): DesktopClient { return { name: blankToNull(request.headers.get('x-nevermind-client')), version: blankToNull(request.headers.get('x-nevermind-client-version')), - apiVersion: parsePositiveInteger(request.headers.get('x-nevermind-api-version')), + apiVersion: parseInteger(request.headers.get('x-nevermind-api-version')), platform: blankToNull(request.headers.get('x-nevermind-platform')), arch: blankToNull(request.headers.get('x-nevermind-arch')), }; @@ -265,7 +265,7 @@ function blankToNull(value: string | null) { return trimmed ? trimmed : null; } -function parsePositiveInteger(value: string | null) { +function parseInteger(value: string | null) { const parsed = Number.parseInt(value || '', 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + return Number.isFinite(parsed) ? parsed : null; } diff --git a/backend/src/pages/api/compatibility.ts b/backend/src/pages/api/compatibility.ts index c3acac6..78aa598 100644 --- a/backend/src/pages/api/compatibility.ts +++ b/backend/src/pages/api/compatibility.ts @@ -1,7 +1,7 @@ import type { APIRoute } from 'astro'; import { compatibilityHeaders, compatibilityManifestForRequest, requestIdFromHeaders } from '../../lib/compatibility'; -export const GET: APIRoute = ({ request }) => { +export const GET: APIRoute = function getCompatibility({ request }) { const requestId = requestIdFromHeaders(request.headers); return Response.json(compatibilityManifestForRequest(request, { requestId, route: 'compatibility' }), { headers: compatibilityHeaders(requestId), diff --git a/backend/src/pages/api/contract-routes.test.ts b/backend/src/pages/api/contract-routes.test.ts index 863f62b..7bb3e1a 100644 --- a/backend/src/pages/api/contract-routes.test.ts +++ b/backend/src/pages/api/contract-routes.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; +import type { APIContext } from 'astro'; import { setDbForTests, resetDbForTests } from '../../db/client'; import { resetRateLimitOverridesForTests, setRateLimitOverridesForTests } from '../../lib/ratelimit'; import { POST as initiateDeviceAuth } from './auth/device/initiate'; @@ -8,6 +9,7 @@ import { GET as getActiveModel } from './v1/active-model'; import { POST as postChatCompletion } from './v1/chat/completions'; type FakeDb = ReturnType; +type MinimalAPIContext = Pick; const originalFetch = globalThis.fetch; @@ -45,8 +47,8 @@ function createFakeDb(input: { selects?: unknown[]; inserts?: unknown[]; updates return db; } -function routeContext(request: Request, url = new URL(request.url)) { - return { request, url } as any; +function routeContext(request: Request, url = new URL(request.url)): MinimalAPIContext { + return { request, url }; } function installDb(db: FakeDb) { diff --git a/package.json b/package.json index e96f9cb..9294cbb 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "electron-updater": "^6.3.9", "file-icon": "^6.0.0", "lucide-react": "latest", - "module-details-from-path": "1.0.4", "ms": "^2.1.3", "react": "latest", "react-dom": "latest", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 893295c..d610950 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,9 +53,6 @@ importers: lucide-react: specifier: latest version: 1.16.0(react@19.2.6) - module-details-from-path: - specifier: 1.0.4 - version: 1.0.4 ms: specifier: ^2.1.3 version: 2.1.3 diff --git a/src/electron/ai.ts b/src/electron/ai.ts index 3eec465..9da32d5 100644 --- a/src/electron/ai.ts +++ b/src/electron/ai.ts @@ -415,17 +415,13 @@ function aiLimitNoticeFromError(error: unknown): AiLimitNotice | null { title: 'Update Nevermind', message: 'This version of Nevermind is no longer supported by the backend. Install the latest version to keep using AI features.', actionTitle: 'Check for Update', - dashboardUrl: updateUrlFromErrorText(text) || NEVERMIND_UPDATE_URL, + dashboardUrl: NEVERMIND_UPDATE_URL, action: { type: 'checkForUpdates', title: 'Check for Update' }, } } return null } -function updateUrlFromErrorText(text: string) { - return text.match(/https:\/\/[^\s"'}]+/i)?.[0] -} - function searchableErrorText(error: unknown) { if (!error) return '' if (!(error instanceof Error)) return stringifyError(error) diff --git a/src/electron/main.ts b/src/electron/main.ts index cd0eee3..e4e50d0 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -1225,17 +1225,31 @@ function patchUpdatesView() { }) } +let activeNevermindBaseUrl: string | null = null + +function safeExternalUpdateUrl(raw?: string) { + if (!raw) return null + try { + const parsed = new URL(raw) + return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.toString() : null + } catch { + return null + } +} + function updateActionForCompatibilityPrompt(updateUrl?: string) { + const safeUrl = safeExternalUpdateUrl(updateUrl) const downloadedInfo = isNewerVersion(updateManager.state.downloadedInfo?.version) ? updateManager.state.downloadedInfo : null const availableInfo = isNewerVersion(updateManager.state.availableInfo?.version) ? updateManager.state.availableInfo : null if (downloadedInfo) return { type: 'installUpdate', title: `Install Nevermind ${downloadedInfo.version || ''}`.trim() } if (availableInfo) return { type: 'downloadUpdate', title: `Download Nevermind ${availableInfo.version || ''}`.trim() } if (updateManager.canUseAutoUpdates()) return { type: 'checkForUpdates', title: 'Check for Update' } - return updateUrl ? { type: 'openUrl', title: 'Download Update', url: updateUrl } : undefined + return safeUrl ? { type: 'openUrl', title: 'Download Update', url: safeUrl } : undefined } function compatibilityPromptAction() { - const manifest = currentNevermindCompatibilityManifest() + if (!activeNevermindBaseUrl) return null + const manifest = currentNevermindCompatibilityManifest(activeNevermindBaseUrl) if (manifest?.client?.compatible !== false) return null const version = manifest.desktop?.latestVersion || manifest.desktop?.minimumSupportedVersion || '' const primaryAction = updateActionForCompatibilityPrompt(manifest.desktop?.updateUrl) @@ -3329,6 +3343,7 @@ function createAccountExtension() { title: 'Log out', __handler: async () => { const { revoked } = await signOutFromNevermind() + activeNevermindBaseUrl = null await nevermindAi?.disposeAllSessions?.() invalidateExtensionRootItems() broadcastAuthChanged({ authed: false }) @@ -5216,6 +5231,7 @@ app.whenReady().then(async () => { }) ipcMain.handle('nevermind:auth-status', async () => { const auth = await getNevermindAuth() + activeNevermindBaseUrl = auth?.baseUrl || null if (auth?.baseUrl) warmNevermindCompatibilityCache(auth.baseUrl) logInfo('nevermind.auth-status.check', { authed: Boolean(auth), email: auth?.email, userData: app.getPath('userData') }, { source: 'host', scope: 'nevermind' }) return auth ? { authed: true, email: auth.email } : { authed: false } @@ -5223,6 +5239,7 @@ app.whenReady().then(async () => { ipcMain.handle('nevermind:sign-in', async () => { const result = await signInToNevermind() if (result.ok) { + activeNevermindBaseUrl = result.auth.baseUrl warmNevermindCompatibilityCache(result.auth.baseUrl) invalidateExtensionRootItems() broadcastAuthChanged({ authed: true, email: result.auth.email }) diff --git a/src/electron/nevermind-api.ts b/src/electron/nevermind-api.ts index e13ff5f..2864c4d 100644 --- a/src/electron/nevermind-api.ts +++ b/src/electron/nevermind-api.ts @@ -4,11 +4,11 @@ export const NEVERMIND_DESKTOP_API_VERSION = '1' export function nevermindDesktopHeaders(headers: Record = {}) { return { + ...headers, 'X-Nevermind-Client': 'desktop', 'X-Nevermind-Client-Version': app.getVersion(), 'X-Nevermind-API-Version': NEVERMIND_DESKTOP_API_VERSION, 'X-Nevermind-Platform': process.platform, 'X-Nevermind-Arch': process.arch, - ...headers, } } diff --git a/src/electron/nevermind-compatibility.ts b/src/electron/nevermind-compatibility.ts index dd25965..5e1f534 100644 --- a/src/electron/nevermind-compatibility.ts +++ b/src/electron/nevermind-compatibility.ts @@ -30,6 +30,7 @@ type CompatibilityCacheFile = { type CompatibilityListener = () => void const CACHE_FILENAME = 'nevermind-compatibility.json' +const COMPATIBILITY_FETCH_TIMEOUT_MS = 5_000 const cachedManifests = new Map() const listeners = new Set() let cacheLoadPromise: Promise | null = null @@ -105,7 +106,14 @@ export async function checkNevermindCompatibility(baseUrl: string) { async function fetchCompatibilityManifest(baseUrl: string) { const trimmed = normalizeBaseUrl(baseUrl) - const res = await fetch(`${trimmed}/api/compatibility`, { headers: nevermindDesktopHeaders() }) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), COMPATIBILITY_FETCH_TIMEOUT_MS) + let res: Response + try { + res = await fetch(`${trimmed}/api/compatibility`, { headers: nevermindDesktopHeaders(), signal: controller.signal }) + } finally { + clearTimeout(timeout) + } if (res.status === 404) return null if (!res.ok) { logger.warn('nevermind.compatibility.unavailable', { status: res.status }) diff --git a/src/use-ai-chat.ts b/src/use-ai-chat.ts index 1a61f79..c9cdad1 100644 --- a/src/use-ai-chat.ts +++ b/src/use-ai-chat.ts @@ -4,9 +4,17 @@ import type { CommandAction, CommandView } from './model' export type AiLimitState = { kind?: string; title: string; message: string; actionTitle?: string; dashboardUrl?: string; action?: CommandAction } export type AiChatEvent = { type: string; text?: string; message?: string; name?: string; chatId?: string; label?: string; data?: unknown } +function isSafeLimitAction(action: unknown): action is CommandAction { + if (!action || typeof action !== 'object') return false + const candidate = action as { type?: unknown; title?: unknown } + if (typeof candidate.type !== 'string' || typeof candidate.title !== 'string') return false + return Object.values(action).every((value) => typeof value !== 'function') +} + function limitStateFromEvent(event: AiChatEvent): AiLimitState | null { const data = event.data as AiLimitState | undefined - if (!data || typeof data !== 'object' || !data.title || !data.message) return null + if (!data || typeof data !== 'object' || typeof data.title !== 'string' || typeof data.message !== 'string') return null + if (data.action && !isSafeLimitAction(data.action)) return { ...data, action: undefined } return data }