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/.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/.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 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..b6d9b02 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,3 +18,13 @@ 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 +# 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/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/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/fixtures/contracts/desktop-v1/compatibility-manifest.json b/backend/src/fixtures/contracts/desktop-v1/compatibility-manifest.json new file mode 100644 index 0000000..065d0ad --- /dev/null +++ b/backend/src/fixtures/contracts/desktop-v1/compatibility-manifest.json @@ -0,0 +1,31 @@ +{ + "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": { + "active_model_descriptor": true, + "proxy_streaming": true, + "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 new file mode 100644 index 0000000..9a0fede --- /dev/null +++ b/backend/src/lib/compatibility.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { afterEach, test } from 'node:test'; +import { + backendKillSwitchEnabled, + compareVersions, + compatibilityError, + compatibilityFeaturesForClient, + 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; + delete process.env.NEVERMIND_FEATURE_FLAGS; + delete process.env.NEVERMIND_KILL_SWITCHES; + 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: { + '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'); + assert.equal(unsupportedClientReason({ name: 'desktop', version: '0.6.0', apiVersion: 0, 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 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, { active_model_descriptor: true, proxy_streaming: true, 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' }), { + active_model_descriptor: true, + proxy_streaming: true, + enabled: true, + needs_newer_desktop: false, + allowed_user_plan: true, + blocked_user_plan: false, + zero_rollout: false, + full_rollout: true, + }); +}); + +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'; + 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'; + 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.deepEqual(body, fixture('unsupported-client-error')); +}); diff --git a/backend/src/lib/compatibility.ts b/backend/src/lib/compatibility.ts new file mode 100644 index 0000000..9293d34 --- /dev/null +++ b/backend/src/lib/compatibility.ts @@ -0,0 +1,271 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { log } from './log'; + +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; +}; + +const DEFAULT_FEATURES: Record = { + active_model_descriptor: true, + proxy_streaming: true, +}; + +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; + 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: parseInteger(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, context: FeatureFlagContext = {}): CompatibilityManifest { + const client = desktopClientFromRequest(request); + const unsupportedReason = unsupportedClientReason(client); + const features = compatibilityFeaturesForClient(client, context); + logDesktopClientSeen(client, context, !unsupportedReason); + logFeatureEvaluations(features, client, context); + 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 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 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'; + return null; +} + +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: { + 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 featureFlagDefinitions(): Record { + const raw = process.env.NEVERMIND_FEATURE_FLAGS?.trim(); + if (!raw) return DEFAULT_FEATURES; + if (!raw.startsWith('{')) { + 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) ? { ...DEFAULT_FEATURES, ...parsed } : DEFAULT_FEATURES; + } catch (error) { + log.warn('feature_flags_parse_failed', { error }); + return DEFAULT_FEATURES; + } +} + +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 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', { + 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, '') + .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 parseInteger(value: string | null) { + const parsed = Number.parseInt(value || '', 10); + return Number.isFinite(parsed) ? parsed : null; +} 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/proxy.ts b/backend/src/lib/proxy.ts index f5113a0..9cc5a53 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 { backendKillSwitchEnabled, backendVersion, desktopClientFromRequest, killSwitchResponse, 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,25 @@ 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(); + 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); + 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 +283,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; @@ -288,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/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/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/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/compatibility.ts b/backend/src/pages/api/compatibility.ts new file mode 100644 index 0000000..78aa598 --- /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 = 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 new file mode 100644 index 0000000..7bb3e1a --- /dev/null +++ b/backend/src/pages/api/contract-routes.test.ts @@ -0,0 +1,264 @@ +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'; +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; +type MinimalAPIContext = Pick; + +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)): MinimalAPIContext { + return { request, url }; +} + +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; + delete process.env.NEVERMIND_KILL_SWITCHES; +}); + +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 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); + 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 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); + 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/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..9294cbb 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 && 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", @@ -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/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/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 new file mode 100644 index 0000000..31b7c64 --- /dev/null +++ b/src/docs/backend-api-compatibility.md @@ -0,0 +1,89 @@ +# 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. + +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. + +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 + +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. 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 + +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. + +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. +- Before sunsetting an API major, query `desktop_client_seen` logs for active clients by `client_api_version` and `client_version`. + +## 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/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 diff --git a/src/electron/ai.ts b/src/electron/ai.ts index 3b29ea4..9da32d5 100644 --- a/src/electron/ai.ts +++ b/src/electron/ai.ts @@ -4,6 +4,9 @@ import { pathToFileURL } from 'node:url' import ts from 'typescript' import * as logger from './logger' import { readRecentLogs, type LogLevel, type LogSource } from './logger' +import { checkNevermindCompatibility, requireNevermindCompatibilityFeature } from './nevermind-compatibility' +import type { CommandAction } from '../model' +import { nevermindDesktopHeaders } from './nevermind-api' import { getNevermindAuth, NevermindAuthRequiredError } from './nevermind-auth' type AiEvent = { @@ -18,15 +21,17 @@ 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 dashboardUrl?: string + action?: CommandAction retryAfterSec?: number } const NEVERMIND_DASHBOARD_URL = 'https://nvm.fyi/dashboard' +const NEVERMIND_UPDATE_URL = 'https://github.com/pablopunk/nvm/releases/latest' type ActiveChat = { id?: string @@ -404,6 +409,16 @@ 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: 'Check for Update', + dashboardUrl: NEVERMIND_UPDATE_URL, + action: { type: 'checkForUpdates', title: 'Check for Update' }, + } + } return null } @@ -474,8 +489,10 @@ type BackendDescriptor = { async function fetchActiveModelDescriptor(baseUrl: string, token: string): Promise { const trimmed = baseUrl.replace(/\/$/, '') + const manifest = await checkNevermindCompatibility(trimmed) + requireNevermindCompatibilityFeature('active_model_descriptor', manifest) 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/main.ts b/src/electron/main.ts index 3ddcf9c..e4e50d0 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,45 @@ 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 safeUrl ? { type: 'openUrl', title: 'Download Update', url: safeUrl } : undefined +} + +function compatibilityPromptAction() { + 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) + 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 +3172,7 @@ function createUpdatesExtension() { ...extension, commands: [{ ...checkItem(), run: () => checkForUpdatesView() }], rootItems() { - return [updatePromptAction() || checkItem()] + return [compatibilityPromptAction() || updatePromptAction() || checkItem()] }, } } @@ -3303,6 +3343,7 @@ function createAccountExtension() { title: 'Log out', __handler: async () => { const { revoked } = await signOutFromNevermind() + activeNevermindBaseUrl = null await nevermindAi?.disposeAllSessions?.() invalidateExtensionRootItems() broadcastAuthChanged({ authed: false }) @@ -5134,7 +5175,8 @@ app.whenReady().then(async () => { registerLocalFileProtocol() installPermissionHandlers(isDev) updateManager.configure() - updateManager.onStateChange(() => patchUpdatesView()) + updateManager.onStateChange(() => { patchUpdatesView(); invalidateExtensionRootItems() }) + onNevermindCompatibilityChanged(() => invalidateExtensionRootItems()) await loadUserState() registerHostJobs() @@ -5189,12 +5231,16 @@ 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 } }) 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 }) return { ok: true, email: result.auth.email } diff --git a/src/electron/nevermind-api.ts b/src/electron/nevermind-api.ts new file mode 100644 index 0000000..2864c4d --- /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 { + ...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, + } +} 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..5e1f534 --- /dev/null +++ b/src/electron/nevermind-compatibility.ts @@ -0,0 +1,171 @@ +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' + +export type NevermindCompatibilityManifest = { + client?: { + compatible?: boolean + unsupportedReason?: string | null + } + desktop?: { + minimumSupportedVersion?: string + latestVersion?: string | null + updateUrl?: string + } + features?: Record +} + +type CachedCompatibilityManifest = { + baseUrl: string + fetchedAt: string + manifest: NevermindCompatibilityManifest +} + +type CompatibilityCacheFile = { + manifests?: Record +} + +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 + +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 + 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 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 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() + notifyCompatibilityChanged() + await fetchCompatibilityManifest(baseUrl) + })().catch((error) => logger.warn('nevermind.compatibility.warm.failed', error as Error)) +} + +export async function checkNevermindCompatibility(baseUrl: string) { + 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 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 }) + return null + } + const manifest = (await res.json().catch(() => null)) as NevermindCompatibilityManifest | null + if (!manifest) return null + 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.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 0445be9..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,8 +97,13 @@ 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 }) { - const action = limit.dashboardUrl ? { +export function NevermindLimitGate({ limit, runAction }: { limit: AiLimitState; runAction: (action: CommandAction) => void }) { + 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/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' diff --git a/src/use-ai-chat.ts b/src/use-ai-chat.ts index 47c7b1b..c9cdad1 100644 --- a/src/use-ai-chat.ts +++ b/src/use-ai-chat.ts @@ -1,12 +1,20 @@ 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 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 }