Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .agents/skills/backend-api-compatibility/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -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.

4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 13 additions & 2 deletions backend/src/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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": []
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
150 changes: 150 additions & 0 deletions backend/src/lib/compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
Loading
Loading