From 827807111cb5162226e5bcadc98b11f3edf6e545 Mon Sep 17 00:00:00 2001 From: Hugo Bjork Date: Mon, 7 Sep 2026 22:38:25 +0200 Subject: [PATCH 1/4] fix(auth): resolve seat via /v1/auth/status instead of JWT roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI gated 'berget code init' on hasBergetCodeSeat(JWT) — a Keycloak realm-role check. That broke Pro/Summit subscribers twice over: the config deliberately omits keycloakRole for the newer tiers (berget.seat- only), so those users never got ANY seat role, and roles drift anyway (a Summit subscriber can carry a stale berget_code_seat role — verified live). The API already authorizes inference against berget.seat (Odoo), not roles. configureAuth now asks GET /v1/auth/status (same canonical source the API authorizes against) and routes on tier: - seat → 'You have a subscription' (tier-aware message) - no seat → API-key path (unchanged) - status unverifiable (network/5xx) → warn + sync OAuth anyway (same behavior as the old undecodable-JWT path) hasBergetCodeSeat and its role list are removed — roles are no longer read anywhere in the CLI. New SeatStatusPort keeps the ports/adapters pattern; production impl never throws (null on any failure). No backend changes needed: /v1/auth/status already exposes seatId/tier resolved from berget.seat for every tier. --- src/auth/__tests__/jwt.test.ts | 54 ------------- src/auth/__tests__/seat-status.test.ts | 63 +++++++++++++++ src/auth/index.ts | 2 +- src/auth/jwt.ts | 28 ------- src/auth/seat-status.ts | 33 ++++++++ src/commands/code/__tests__/auth-sync.test.ts | 81 ++++++++++++------- .../__tests__/fake-seat-status-service.ts | 13 +++ src/commands/code/auth-sync.ts | 42 +++++++--- src/commands/code/init.ts | 2 + src/commands/code/ports/auth-services.ts | 16 ++++ 10 files changed, 210 insertions(+), 124 deletions(-) create mode 100644 src/auth/__tests__/seat-status.test.ts create mode 100644 src/auth/seat-status.ts create mode 100644 src/commands/code/__tests__/fake-seat-status-service.ts diff --git a/src/auth/__tests__/jwt.test.ts b/src/auth/__tests__/jwt.test.ts index df54719..ff5893f 100644 --- a/src/auth/__tests__/jwt.test.ts +++ b/src/auth/__tests__/jwt.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'; import { decodeJwtPayload, extractJwtExpiresAt, - hasBergetCodeSeat, isTokenExpired, } from '../jwt.js'; @@ -89,56 +88,3 @@ describe('isTokenExpired', () => { }); }); -describe('hasBergetCodeSeat', () => { - it('returns true when berget_code_seat is present', () => { - const token = makeJwt({ - realm_access: { roles: ['berget_code_seat', 'default-roles-berget'] }, - }); - expect(hasBergetCodeSeat(token)).toBe(true); - }); - - it('returns true for a Pro seat (seat_plan_pro_seat)', () => { - const token = makeJwt({ - realm_access: { roles: ['seat_plan_pro_seat', 'default-roles-berget'] }, - }); - expect(hasBergetCodeSeat(token)).toBe(true); - }); - - it('returns true for a Summit seat (seat_plan_summit_seat)', () => { - const token = makeJwt({ - realm_access: { roles: ['seat_plan_summit_seat', 'default-roles-berget'] }, - }); - expect(hasBergetCodeSeat(token)).toBe(true); - }); - - it('returns true for a Mini seat (seat_plan_mini_seat)', () => { - const token = makeJwt({ - realm_access: { roles: ['seat_plan_mini_seat', 'default-roles-berget'] }, - }); - expect(hasBergetCodeSeat(token)).toBe(true); - }); - - it('returns false for look-alike roles that are not seat roles', () => { - const token = makeJwt({ - realm_access: { roles: ['seat_plan_pro', 'berget_code', 'seat_plan_pro_seat_v2'] }, - }); - expect(hasBergetCodeSeat(token)).toBe(false); - }); - - it('returns false when role is missing', () => { - const token = makeJwt({ - realm_access: { roles: ['default-roles-berget'] }, - }); - expect(hasBergetCodeSeat(token)).toBe(false); - }); - - it('returns false when realm_access is missing', () => { - const token = makeJwt({ sub: '123' }); - expect(hasBergetCodeSeat(token)).toBe(false); - }); - - it('returns false for invalid JWT', () => { - expect(hasBergetCodeSeat('invalid')).toBe(false); - expect(hasBergetCodeSeat('only.two')).toBe(false); - }); -}); diff --git a/src/auth/__tests__/seat-status.test.ts b/src/auth/__tests__/seat-status.test.ts new file mode 100644 index 0000000..cfa80cd --- /dev/null +++ b/src/auth/__tests__/seat-status.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createSeatStatusService } from '../seat-status.js'; + +const BASE = 'https://api.berget.ai'; + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.BERGET_API_URL; +}); + +describe('seat-status service (GET /v1/auth/status)', () => { + it('returns seatId and tier when the user has a seat', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ authenticated: true, seatId: 168, tier: 'seat_plan_pro' }), + }), + ); + const svc = createSeatStatusService(); + expect(await svc.fetchSeatStatus('token')).toEqual({ + seatId: 168, + tier: 'seat_plan_pro', + }); + expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe(`${BASE}/v1/auth/status`); + }); + + it('returns null seat when the user has no seat', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ authenticated: true, seatId: null, tier: null }), + }), + ); + const svc = createSeatStatusService(); + expect(await svc.fetchSeatStatus('token')).toEqual({ seatId: null, tier: null }); + }); + + it('returns null on non-OK responses (e.g. 401)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }), + ); + const svc = createSeatStatusService(); + expect(await svc.fetchSeatStatus('token')).toBeNull(); + }); + + it('returns null on network errors (never throws)', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ENOTFOUND'))); + const svc = createSeatStatusService(); + expect(await svc.fetchSeatStatus('token')).toBeNull(); + }); + + it('honours BERGET_API_URL override', async () => { + process.env.BERGET_API_URL = 'https://api.stage.berget.ai'; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + const svc = createSeatStatusService(); + await svc.fetchSeatStatus('token'); + expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe('https://api.stage.berget.ai/v1/auth/status'); + }); +}); diff --git a/src/auth/index.ts b/src/auth/index.ts index 72a6331..5e2af30 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -3,7 +3,7 @@ export { getAuthConfig } from './config.js'; export { resolveApiKey } from './credentials/api-key.js'; export { resolveAuth } from './credentials/resolver.js'; export { clearConfigurationCache, getConfiguration } from './issuer.js'; -export { decodeJwtPayload, extractJwtExpiresAt, hasBergetCodeSeat, isTokenExpired } from './jwt.js'; +export { decodeJwtPayload, extractJwtExpiresAt, isTokenExpired } from './jwt.js'; export { authMiddleware } from './middleware/auth-middleware.js'; export { startPkceFlow } from './oauth/pkce-flow.js'; export { refreshAccessToken } from './oauth/token-refresh.js'; diff --git a/src/auth/jwt.ts b/src/auth/jwt.ts index cbe1fbd..7299aa8 100644 --- a/src/auth/jwt.ts +++ b/src/auth/jwt.ts @@ -20,34 +20,6 @@ export function extractJwtExpiresAt(accessToken: string): number { return 0; } -/** - * Seat roles that grant a paid Berget subscription, one per tier. - * `berget_code` is the legacy identifier for the Standard tier (do not rename); - * newer tiers follow `seat_plan_`. Mirrors - * backend-api/src/config/seat-product.config.ts. - */ -const SEAT_ROLES = [ - 'berget_code_seat', - 'seat_plan_mini_seat', - 'seat_plan_pro_seat', - 'seat_plan_summit_seat', -] as const; - -/** - * Check if the JWT token has any paid seat role (any tier: Standard/Mini/Pro/Summit). - * @param accessToken The JWT access token - * @returns true if the token holds a seat role, false otherwise - */ -export function hasBergetCodeSeat(accessToken: string): boolean { - const decoded = parseJwtBody(accessToken); - if (!decoded) return false; - const realmAccess = decoded.realm_access as Record | undefined; - if (!realmAccess) return false; - const roles = realmAccess.roles as string[] | undefined; - if (!Array.isArray(roles)) return false; - return roles.some((role) => (SEAT_ROLES as readonly string[]).includes(role)); -} - /** * Check if a token is expired with a configurable buffer. * Uses 10% of remaining lifetime or 30 seconds, whichever is smaller. diff --git a/src/auth/seat-status.ts b/src/auth/seat-status.ts new file mode 100644 index 0000000..37c57b8 --- /dev/null +++ b/src/auth/seat-status.ts @@ -0,0 +1,33 @@ +import type { SeatStatusPort } from '../commands/code/ports/auth-services.js'; + +/** + * Resolves the caller's seat via GET /v1/auth/status — the API resolves the + * seat from berget.seat (Odoo), the same canonical source it authorizes + * inference against. Keycloak JWT roles are legacy duplicated state and are + * deliberately NOT consulted (they drift: e.g. a stale berget_code_seat role + * on a Summit subscriber). + * + * Never throws: any failure (network, non-OK, bad payload) returns null so + * the caller can fall back to a warn-and-continue path. + */ +export function createSeatStatusService(): SeatStatusPort { + return { + async fetchSeatStatus(accessToken: string) { + const base = process.env.BERGET_API_URL || 'https://api.berget.ai'; + try { + const res = await fetch(`${base}/v1/auth/status`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) return null; + const data = (await res.json()) as { seatId?: number | null; tier?: string | null }; + const seatStatus: { seatId: null | number; tier: null | string } = { + seatId: data.seatId ?? null, + tier: data.tier ?? null, + }; + return seatStatus; + } catch { + return null; + } + }, + }; +} diff --git a/src/commands/code/__tests__/auth-sync.test.ts b/src/commands/code/__tests__/auth-sync.test.ts index e449fbc..889e40c 100644 --- a/src/commands/code/__tests__/auth-sync.test.ts +++ b/src/commands/code/__tests__/auth-sync.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { decodeJwtPayload, hasBergetCodeSeat } from '../../../auth/jwt.js'; +import { decodeJwtPayload } from '../../../auth/jwt.js'; import { type AuthDeps, type CliAuth, @@ -14,6 +14,7 @@ import { import { FatalError } from '../errors.js'; import { FakeApiKeyService } from './fake-api-key-service.js'; import { FakeAuthService } from './fake-auth-service.js'; +import { FakeSeatStatusService } from './fake-seat-status-service.js'; import { FakeFileStore } from './fake-file-store.js'; import { confirm, FakePrompter, select } from './fake-prompter.js'; @@ -151,31 +152,6 @@ describe('decodeJwtPayload', () => { }); }); -describe('hasBergetCodeSeat', () => { - it('returns true when berget_code_seat is present', () => { - const token = makeJwt({ - realm_access: { roles: ['berget_code_seat', 'default-roles-berget'] }, - }); - expect(hasBergetCodeSeat(token)).toBe(true); - }); - - it('returns false when role is missing', () => { - const token = makeJwt({ - realm_access: { roles: ['default-roles-berget'] }, - }); - expect(hasBergetCodeSeat(token)).toBe(false); - }); - - it('returns false when realm_access is missing', () => { - const token = makeJwt({ sub: '123' }); - expect(hasBergetCodeSeat(token)).toBe(false); - }); - - it('returns false for invalid JWT', () => { - expect(hasBergetCodeSeat('invalid')).toBe(false); - }); -}); - describe('syncOAuthToTool', () => { it('writes oauth tokens to opencode auth file', async () => { const files = new FakeFileStore(); @@ -306,6 +282,7 @@ describe('configureAuth', () => { files: new FakeFileStore(), homeDir: HOME, prompter: new FakePrompter([]), + seatStatusService: new FakeSeatStatusService({ seatId: null, tier: null }), ...overrides, }) as AuthDeps; @@ -334,7 +311,11 @@ describe('configureAuth', () => { const prompter = new FakePrompter([select('reconfigure'), select('subscription')]); - const deps = makeAuthDeps({ files, prompter }); + const deps = makeAuthDeps({ + files, + prompter, + seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }), + }); const result = await configureAuth( deps, 'opencode', @@ -363,7 +344,11 @@ describe('configureAuth', () => { const prompter = new FakePrompter([select('subscription')]); - const deps = makeAuthDeps({ files, prompter }); + const deps = makeAuthDeps({ + files, + prompter, + seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }), + }); const result = await configureAuth(deps, 'opencode', cliAuth); expect(result.authenticated).toBe(true); @@ -387,7 +372,11 @@ describe('configureAuth', () => { const prompter = new FakePrompter([select('api_key')]); - const deps = makeAuthDeps({ files, prompter }); + const deps = makeAuthDeps({ + files, + prompter, + seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }), + }); const result = await configureAuth(deps, 'opencode', cliAuth); expect(result.authenticated).toBe(true); @@ -430,7 +419,12 @@ describe('configureAuth', () => { 'Before you can create API keys, you need to finish setting up your account.', ); - const deps = makeAuthDeps({ apiKeyService: failingApiKeyService, files, prompter }); + const deps = makeAuthDeps({ + apiKeyService: failingApiKeyService, + files, + prompter, + seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }), + }); await expect(configureAuth(deps, 'opencode', cliAuth)).rejects.toThrow(FatalError); expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false); @@ -466,6 +460,27 @@ describe('configureAuth', () => { expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false); }); + it('seat status unverifiable (API down) → warns and syncs OAuth anyway', async () => { + const files = new FakeFileStore(); + const prompter = new FakePrompter([]); + + const deps = makeAuthDeps({ + files, + prompter, + seatStatusService: new FakeSeatStatusService(null), + }); + const result = await configureAuth(deps, 'opencode', fakeCliAuth()); + + expect(result.authenticated).toBe(true); + // OAuth synced despite unverified status + const written = files.getWrittenFiles(); + const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!); + expect(parsed.berget.type).toBe('oauth'); + // Warning note displayed + const notes = prompter.calls.filter((c) => c.method === 'note'); + expect(notes.length).toBeGreaterThan(0); + }); + it('Case E: login fails → returns false when cliAuth is null', async () => { const files = new FakeFileStore(); @@ -523,7 +538,11 @@ describe('configureAuth', () => { const prompter = new FakePrompter([select('subscription')]); - const deps = makeAuthDeps({ files, prompter }); + const deps = makeAuthDeps({ + files, + prompter, + seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }), + }); await configureAuth( deps, 'opencode', diff --git a/src/commands/code/__tests__/fake-seat-status-service.ts b/src/commands/code/__tests__/fake-seat-status-service.ts new file mode 100644 index 0000000..d1d3429 --- /dev/null +++ b/src/commands/code/__tests__/fake-seat-status-service.ts @@ -0,0 +1,13 @@ +import type { SeatStatus, SeatStatusPort } from '../ports/auth-services.js'; + +/** + * Test double: pass a SeatStatus for "user has a seat", or null for + * "status could not be verified" (API down). + */ +export class FakeSeatStatusService implements SeatStatusPort { + constructor(private readonly result: SeatStatus | null) {} + + async fetchSeatStatus(): Promise { + return this.result; + } +} diff --git a/src/commands/code/auth-sync.ts b/src/commands/code/auth-sync.ts index 01a2883..b6d752d 100644 --- a/src/commands/code/auth-sync.ts +++ b/src/commands/code/auth-sync.ts @@ -1,11 +1,14 @@ -import type { ApiKeyServicePort, AuthServicePort } from './ports/auth-services.js'; +import type { + ApiKeyServicePort, + AuthServicePort, + SeatStatusPort, +} from './ports/auth-services.js'; import type { FileStore } from './ports/file-store.js'; import type { Prompter } from './ports/prompter.js'; import { decodeJwtPayload, extractJwtExpiresAt, - hasBergetCodeSeat, isTokenExpired, } from '../../auth/jwt.js'; import { logger } from '../../utils/logger.js'; @@ -18,6 +21,7 @@ export interface AuthDeps { files: FileStore; homeDir: string; prompter: Prompter; + seatStatusService: SeatStatusPort; } export interface AuthResult { @@ -75,13 +79,22 @@ export async function configureAuth( const jwtPayload = decodeJwtPayload(cliAuth.access_token); if (!jwtPayload) { - return handleUndecodableJwt(prompter, files, homeDir, tool, cliAuth); + return handleUnverifiedAuth(prompter, files, homeDir, tool, cliAuth); } - const hasSeat = hasBergetCodeSeat(cliAuth.access_token); + // Resolve the seat from the API's canonical source (berget.seat via + // /v1/auth/status) — NOT from JWT roles, which are legacy duplicated state + // and drift (e.g. a stale berget_code_seat role on a Summit subscriber). + const seatStatus = await deps.seatStatusService.fetchSeatStatus(cliAuth.access_token); - if (hasSeat) { - return handleHasSeat(prompter, apiKeyService, files, homeDir, tool, cliAuth); + if (seatStatus === null) { + // Status could not be verified (network/5xx) — proceed with the OAuth + // sync and a warning, same as the undecodable-JWT path. + return handleUnverifiedAuth(prompter, files, homeDir, tool, cliAuth); + } + + if (seatStatus.tier) { + return handleHasSeat(prompter, apiKeyService, files, homeDir, tool, cliAuth, seatStatus.tier); } return handleNoSeat(prompter, apiKeyService, files, homeDir, tool); @@ -266,6 +279,13 @@ async function createAndSyncApiKey( } } +const TIER_LABELS: Record = { + berget_code: 'Berget Code', + seat_plan_mini: 'Berget Chat', + seat_plan_pro: 'Berget Pro', + seat_plan_summit: 'Berget Summit', +}; + async function handleHasSeat( prompter: Prompter, apiKeyService: ApiKeyServicePort, @@ -273,11 +293,13 @@ async function handleHasSeat( homeDir: string, tool: 'opencode' | 'pi', cliAuth: CliAuth, + tier: string, ): Promise { + const tierLabel = TIER_LABELS[tier] ?? 'Berget'; const method = await prompter.select<'api_key' | 'subscription'>({ - message: 'You have a Berget subscription. How do you want to authenticate?', + message: `You have a ${tierLabel} subscription. How do you want to authenticate?`, options: [ - { label: 'Use my Berget Code subscription', value: 'subscription' }, + { label: `Use my ${tierLabel} subscription`, value: 'subscription' }, { label: 'Use an API key instead', value: 'api_key' }, ], }); @@ -321,7 +343,7 @@ async function handleNoSeat( return { authenticated: false }; } -async function handleUndecodableJwt( +async function handleUnverifiedAuth( prompter: Prompter, files: FileStore, homeDir: string, @@ -338,7 +360,7 @@ async function handleUndecodableJwt( throw error; } prompter.note( - 'Warning: Could not verify Berget Code subscription status.\nIf you do not have a subscription, the tool may show an authorization error.', + 'Warning: Could not verify your subscription status.\nIf you do not have a subscription, the tool may show an authorization error.', 'Authentication', ); return { authenticated: true }; diff --git a/src/commands/code/init.ts b/src/commands/code/init.ts index 2f8fb1b..fb4906e 100644 --- a/src/commands/code/init.ts +++ b/src/commands/code/init.ts @@ -11,6 +11,7 @@ import { AuthService } from '../../services/auth-service.js'; import { ClackPrompter } from './adapters/clack-prompter.js'; import { FsFileStore } from './adapters/fs-file-store.js'; import { SpawnCommandRunner } from './adapters/spawn-command-runner.js'; +import { createSeatStatusService } from '../../auth/seat-status.js'; import { configureAuth, ensureCliAuth } from './auth-sync.js'; import { CancelledError, CommandFailedError, FatalError, PrerequisiteError } from './errors.js'; import { @@ -129,6 +130,7 @@ export async function runInitCommand(): Promise { homeDir: os.homedir(), isTty: process.stdin.isTTY, prompter: new ClackPrompter(), + seatStatusService: createSeatStatusService(), }); if (result.stderr) console.error(result.stderr); diff --git a/src/commands/code/ports/auth-services.ts b/src/commands/code/ports/auth-services.ts index f2442d9..78368a4 100644 --- a/src/commands/code/ports/auth-services.ts +++ b/src/commands/code/ports/auth-services.ts @@ -12,3 +12,19 @@ export interface AuthServicePort { success: boolean; }>; } + +export interface SeatStatus { + seatId: number | null; + tier: string | null; +} + +/** + * Resolves the caller's seat from the API's canonical source (berget.seat in + * Odoo, via GET /v1/auth/status) — NOT from Keycloak JWT roles, which are + * legacy duplicated state and no longer written for newer tiers. + * Returns null when the status cannot be verified (network/5xx/401) so the + * caller can fall back to a warn-and-continue path. + */ +export interface SeatStatusPort { + fetchSeatStatus(accessToken: string): Promise; +} From d13eff9ceb2dfae023117451b0db6e9939f183ad Mon Sep 17 00:00:00 2001 From: Hugo Bjork Date: Mon, 7 Sep 2026 22:44:23 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20AI=20review=20=E2=80=94=20?= =?UTF-8?q?build=20break,=20config=20reuse,=20timeout,=20seat=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - configureAuth Pick now includes seatStatusService (was TS2339 build break, hidden by vitest's type-stripping) - reuse getAuthConfig().apiBaseUrl instead of duplicating env resolution (diverged in --local mode: localhost:3000 vs prod) - AbortSignal.timeout(10s) so a hung connection can't block init - seat gate keys on seatId OR tier: {seatId, tier: null} no longer sends a real seat-holder to the 'no subscription' path (test pinned) - move adapter to commands/code/adapters/ (matches clack-prompter etc., removes inverted auth→commands dependency) - vi.stubEnv in tests (matches config.test.ts pattern) --- src/auth/__tests__/seat-status.test.ts | 6 +++--- src/commands/code/__tests__/auth-sync.test.ts | 18 ++++++++++++++++++ .../code/adapters}/seat-status.ts | 10 ++++++---- src/commands/code/auth-sync.ts | 8 ++++---- src/commands/code/init.ts | 2 +- 5 files changed, 32 insertions(+), 12 deletions(-) rename src/{auth => commands/code/adapters}/seat-status.ts (72%) diff --git a/src/auth/__tests__/seat-status.test.ts b/src/auth/__tests__/seat-status.test.ts index cfa80cd..1186680 100644 --- a/src/auth/__tests__/seat-status.test.ts +++ b/src/auth/__tests__/seat-status.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createSeatStatusService } from '../seat-status.js'; +import { createSeatStatusService } from '../../commands/code/adapters/seat-status.js'; const BASE = 'https://api.berget.ai'; afterEach(() => { vi.unstubAllGlobals(); - delete process.env.BERGET_API_URL; + vi.unstubAllEnvs(); }); describe('seat-status service (GET /v1/auth/status)', () => { @@ -54,7 +54,7 @@ describe('seat-status service (GET /v1/auth/status)', () => { }); it('honours BERGET_API_URL override', async () => { - process.env.BERGET_API_URL = 'https://api.stage.berget.ai'; + vi.stubEnv('BERGET_API_URL', 'https://api.stage.berget.ai'); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); const svc = createSeatStatusService(); await svc.fetchSeatStatus('token'); diff --git a/src/commands/code/__tests__/auth-sync.test.ts b/src/commands/code/__tests__/auth-sync.test.ts index 889e40c..b5eadfb 100644 --- a/src/commands/code/__tests__/auth-sync.test.ts +++ b/src/commands/code/__tests__/auth-sync.test.ts @@ -460,6 +460,24 @@ describe('configureAuth', () => { expect(files.getWrittenFiles().has(HOME + '/.local/share/opencode/auth.json')).toBe(false); }); + it('seat without recognized tier ({seatId, tier: null}) → still treated as seat-holder', async () => { + const files = new FakeFileStore(); + const prompter = new FakePrompter([select('subscription')]); + + const deps = makeAuthDeps({ + files, + prompter, + seatStatusService: new FakeSeatStatusService({ seatId: 168, tier: null }), + }); + const result = await configureAuth(deps, 'opencode', fakeCliAuth()); + + expect(result.authenticated).toBe(true); + // Seat path: OAuth synced, NOT the no-seat API-key prompt + const written = files.getWrittenFiles(); + const parsed = JSON.parse(written.get(HOME + '/.local/share/opencode/auth.json')!); + expect(parsed.berget.type).toBe('oauth'); + }); + it('seat status unverifiable (API down) → warns and syncs OAuth anyway', async () => { const files = new FakeFileStore(); const prompter = new FakePrompter([]); diff --git a/src/auth/seat-status.ts b/src/commands/code/adapters/seat-status.ts similarity index 72% rename from src/auth/seat-status.ts rename to src/commands/code/adapters/seat-status.ts index 37c57b8..4bb33e9 100644 --- a/src/auth/seat-status.ts +++ b/src/commands/code/adapters/seat-status.ts @@ -1,4 +1,5 @@ -import type { SeatStatusPort } from '../commands/code/ports/auth-services.js'; +import { getAuthConfig } from '../../../auth/config.js'; +import type { SeatStatusPort } from '../ports/auth-services.js'; /** * Resolves the caller's seat via GET /v1/auth/status — the API resolves the @@ -7,16 +8,17 @@ import type { SeatStatusPort } from '../commands/code/ports/auth-services.js'; * deliberately NOT consulted (they drift: e.g. a stale berget_code_seat role * on a Summit subscriber). * - * Never throws: any failure (network, non-OK, bad payload) returns null so - * the caller can fall back to a warn-and-continue path. + * Never throws: any failure (network, timeout, non-OK, bad payload) returns + * null so the caller can fall back to a warn-and-continue path. */ export function createSeatStatusService(): SeatStatusPort { return { async fetchSeatStatus(accessToken: string) { - const base = process.env.BERGET_API_URL || 'https://api.berget.ai'; + const base = getAuthConfig().apiBaseUrl.replace(/\/$/, ''); try { const res = await fetch(`${base}/v1/auth/status`, { headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(10_000), }); if (!res.ok) return null; const data = (await res.json()) as { seatId?: number | null; tier?: string | null }; diff --git a/src/commands/code/auth-sync.ts b/src/commands/code/auth-sync.ts index b6d752d..a30e71b 100644 --- a/src/commands/code/auth-sync.ts +++ b/src/commands/code/auth-sync.ts @@ -47,7 +47,7 @@ const TOOL_API_KEY_TYPES: Record<'opencode' | 'pi', string> = { }; export async function configureAuth( - deps: Pick, + deps: Pick, tool: 'opencode' | 'pi', cliAuth: CliAuth | null, ): Promise { @@ -93,7 +93,7 @@ export async function configureAuth( return handleUnverifiedAuth(prompter, files, homeDir, tool, cliAuth); } - if (seatStatus.tier) { + if (seatStatus.seatId != null || seatStatus.tier != null) { return handleHasSeat(prompter, apiKeyService, files, homeDir, tool, cliAuth, seatStatus.tier); } @@ -293,9 +293,9 @@ async function handleHasSeat( homeDir: string, tool: 'opencode' | 'pi', cliAuth: CliAuth, - tier: string, + tier: string | null, ): Promise { - const tierLabel = TIER_LABELS[tier] ?? 'Berget'; + const tierLabel = (tier && TIER_LABELS[tier]) || 'Berget'; const method = await prompter.select<'api_key' | 'subscription'>({ message: `You have a ${tierLabel} subscription. How do you want to authenticate?`, options: [ diff --git a/src/commands/code/init.ts b/src/commands/code/init.ts index fb4906e..3eed620 100644 --- a/src/commands/code/init.ts +++ b/src/commands/code/init.ts @@ -11,7 +11,7 @@ import { AuthService } from '../../services/auth-service.js'; import { ClackPrompter } from './adapters/clack-prompter.js'; import { FsFileStore } from './adapters/fs-file-store.js'; import { SpawnCommandRunner } from './adapters/spawn-command-runner.js'; -import { createSeatStatusService } from '../../auth/seat-status.js'; +import { createSeatStatusService } from './adapters/seat-status.js'; import { configureAuth, ensureCliAuth } from './auth-sync.js'; import { CancelledError, CommandFailedError, FatalError, PrerequisiteError } from './errors.js'; import { From 21061aea720841b84c1569fcc1244a5fadea6b59 Mon Sep 17 00:00:00 2001 From: Hugo Bjork Date: Mon, 7 Sep 2026 22:47:40 +0200 Subject: [PATCH 3/4] fix: wire seatStatusService through WizardDeps (CI build break) tsc caught what local type-stripping hid: WizardDeps lacked the field and the configureAuth call site in runInit didn't pass it. init.test.ts makeDeps now defaults to a no-seat fake; seat-path tests override. --- src/commands/code/__tests__/init.test.ts | 5 +++++ src/commands/code/init.ts | 19 +++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/commands/code/__tests__/init.test.ts b/src/commands/code/__tests__/init.test.ts index 3194a70..e3e9659 100644 --- a/src/commands/code/__tests__/init.test.ts +++ b/src/commands/code/__tests__/init.test.ts @@ -10,6 +10,7 @@ import { FakeAuthService } from './fake-auth-service.js'; import { FakeCommandRunner } from './fake-command-runner.js'; import { FakeFileStore } from './fake-file-store.js'; import { CANCEL, confirm, FakePrompter, multiselect, select } from './fake-prompter.js'; +import { FakeSeatStatusService } from './fake-seat-status-service.js'; const ENV_KEYS = [ 'XDG_CONFIG_HOME', @@ -48,6 +49,8 @@ const makeDeps = ( homeDir: '/home/user', isTty: overrides.isTty ?? true, prompter: overrides.prompter ?? new FakePrompter([]), + seatStatusService: + overrides.seatStatusService ?? new FakeSeatStatusService({ seatId: null, tier: null }), ...Object.fromEntries( Object.entries(overrides).filter( ([k]) => @@ -575,6 +578,7 @@ describe('runInit', () => { confirm(true, 'Create'), multiselect([]), ]), + seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }), }); await runInit(deps); @@ -730,6 +734,7 @@ describe('runInit', () => { select('project'), select('subscription'), ]), + seatStatusService: new FakeSeatStatusService({ seatId: 1, tier: 'berget_code' }), }); await runInit(deps); diff --git a/src/commands/code/init.ts b/src/commands/code/init.ts index 3eed620..9dc6417 100644 --- a/src/commands/code/init.ts +++ b/src/commands/code/init.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import * as os from 'node:os'; -import type { ApiKeyServicePort, AuthServicePort } from './ports/auth-services.js'; +import type { ApiKeyServicePort, AuthServicePort, SeatStatusPort } from './ports/auth-services.js'; import type { CommandRunner } from './ports/command-runner.js'; import type { FileStore } from './ports/file-store.js'; import type { Prompter } from './ports/prompter.js'; @@ -10,8 +10,8 @@ import { ApiKeyService } from '../../services/api-key-service.js'; import { AuthService } from '../../services/auth-service.js'; import { ClackPrompter } from './adapters/clack-prompter.js'; import { FsFileStore } from './adapters/fs-file-store.js'; -import { SpawnCommandRunner } from './adapters/spawn-command-runner.js'; import { createSeatStatusService } from './adapters/seat-status.js'; +import { SpawnCommandRunner } from './adapters/spawn-command-runner.js'; import { configureAuth, ensureCliAuth } from './auth-sync.js'; import { CancelledError, CommandFailedError, FatalError, PrerequisiteError } from './errors.js'; import { @@ -37,6 +37,7 @@ export interface WizardDeps { homeDir: string; isTty?: boolean; prompter: Prompter; + seatStatusService: SeatStatusPort; } export async function executeInitCommand(deps: WizardDeps): Promise { @@ -54,7 +55,17 @@ export async function executeInitCommand(deps: WizardDeps): Promise { - const { apiKeyService, authService, commands, cwd, files, homeDir, isTty, prompter } = deps; + const { + apiKeyService, + authService, + commands, + cwd, + files, + homeDir, + isTty, + prompter, + seatStatusService, + } = deps; prompter.intro(`${chalk.bgGreen.black(' berget code ')}`); prompter.note( @@ -96,7 +107,7 @@ export async function runInit(deps: WizardDeps): Promise { prompter.log('step', 'Configuring authentication...'); const authResult = await configureAuth( - { apiKeyService, files, homeDir, prompter }, + { apiKeyService, files, homeDir, prompter, seatStatusService }, tool, cliAuth, ); From 2820d89e12ab11e6ac749e4bb51b2a1b77504165 Mon Sep 17 00:00:00 2001 From: Hugo Bjork Date: Mon, 7 Sep 2026 22:49:43 +0200 Subject: [PATCH 4/4] style: eslint --fix (prettier + perfectionist ordering) --- src/auth/__tests__/jwt.test.ts | 7 +------ src/auth/__tests__/seat-status.test.ts | 8 ++++---- src/commands/code/__tests__/auth-sync.test.ts | 2 +- .../code/__tests__/fake-seat-status-service.ts | 4 ++-- src/commands/code/adapters/seat-status.ts | 5 +++-- src/commands/code/auth-sync.ts | 14 +++----------- src/commands/code/ports/auth-services.ts | 6 +++--- 7 files changed, 17 insertions(+), 29 deletions(-) diff --git a/src/auth/__tests__/jwt.test.ts b/src/auth/__tests__/jwt.test.ts index ff5893f..8d6b993 100644 --- a/src/auth/__tests__/jwt.test.ts +++ b/src/auth/__tests__/jwt.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - decodeJwtPayload, - extractJwtExpiresAt, - isTokenExpired, -} from '../jwt.js'; +import { decodeJwtPayload, extractJwtExpiresAt, isTokenExpired } from '../jwt.js'; function base64urlEncode(data: string): string { return Buffer.from(data).toString('base64url'); @@ -87,4 +83,3 @@ describe('isTokenExpired', () => { expect(isTokenExpired(farFuture)).toBe(false); }); }); - diff --git a/src/auth/__tests__/seat-status.test.ts b/src/auth/__tests__/seat-status.test.ts index 1186680..46603bb 100644 --- a/src/auth/__tests__/seat-status.test.ts +++ b/src/auth/__tests__/seat-status.test.ts @@ -14,8 +14,8 @@ describe('seat-status service (GET /v1/auth/status)', () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ - ok: true, json: async () => ({ authenticated: true, seatId: 168, tier: 'seat_plan_pro' }), + ok: true, }), ); const svc = createSeatStatusService(); @@ -30,8 +30,8 @@ describe('seat-status service (GET /v1/auth/status)', () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ - ok: true, json: async () => ({ authenticated: true, seatId: null, tier: null }), + ok: true, }), ); const svc = createSeatStatusService(); @@ -41,7 +41,7 @@ describe('seat-status service (GET /v1/auth/status)', () => { it('returns null on non-OK responses (e.g. 401)', async () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }), + vi.fn().mockResolvedValue({ json: async () => ({}), ok: false, status: 401 }), ); const svc = createSeatStatusService(); expect(await svc.fetchSeatStatus('token')).toBeNull(); @@ -55,7 +55,7 @@ describe('seat-status service (GET /v1/auth/status)', () => { it('honours BERGET_API_URL override', async () => { vi.stubEnv('BERGET_API_URL', 'https://api.stage.berget.ai'); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: async () => ({}), ok: true })); const svc = createSeatStatusService(); await svc.fetchSeatStatus('token'); expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe('https://api.stage.berget.ai/v1/auth/status'); diff --git a/src/commands/code/__tests__/auth-sync.test.ts b/src/commands/code/__tests__/auth-sync.test.ts index b5eadfb..7365e31 100644 --- a/src/commands/code/__tests__/auth-sync.test.ts +++ b/src/commands/code/__tests__/auth-sync.test.ts @@ -14,9 +14,9 @@ import { import { FatalError } from '../errors.js'; import { FakeApiKeyService } from './fake-api-key-service.js'; import { FakeAuthService } from './fake-auth-service.js'; -import { FakeSeatStatusService } from './fake-seat-status-service.js'; import { FakeFileStore } from './fake-file-store.js'; import { confirm, FakePrompter, select } from './fake-prompter.js'; +import { FakeSeatStatusService } from './fake-seat-status-service.js'; const ENV_KEYS = [ 'XDG_CONFIG_HOME', diff --git a/src/commands/code/__tests__/fake-seat-status-service.ts b/src/commands/code/__tests__/fake-seat-status-service.ts index d1d3429..f9c71a8 100644 --- a/src/commands/code/__tests__/fake-seat-status-service.ts +++ b/src/commands/code/__tests__/fake-seat-status-service.ts @@ -5,9 +5,9 @@ import type { SeatStatus, SeatStatusPort } from '../ports/auth-services.js'; * "status could not be verified" (API down). */ export class FakeSeatStatusService implements SeatStatusPort { - constructor(private readonly result: SeatStatus | null) {} + constructor(private readonly result: null | SeatStatus) {} - async fetchSeatStatus(): Promise { + async fetchSeatStatus(): Promise { return this.result; } } diff --git a/src/commands/code/adapters/seat-status.ts b/src/commands/code/adapters/seat-status.ts index 4bb33e9..631d949 100644 --- a/src/commands/code/adapters/seat-status.ts +++ b/src/commands/code/adapters/seat-status.ts @@ -1,6 +1,7 @@ -import { getAuthConfig } from '../../../auth/config.js'; import type { SeatStatusPort } from '../ports/auth-services.js'; +import { getAuthConfig } from '../../../auth/config.js'; + /** * Resolves the caller's seat via GET /v1/auth/status — the API resolves the * seat from berget.seat (Odoo), the same canonical source it authorizes @@ -21,7 +22,7 @@ export function createSeatStatusService(): SeatStatusPort { signal: AbortSignal.timeout(10_000), }); if (!res.ok) return null; - const data = (await res.json()) as { seatId?: number | null; tier?: string | null }; + const data = (await res.json()) as { seatId?: null | number; tier?: null | string }; const seatStatus: { seatId: null | number; tier: null | string } = { seatId: data.seatId ?? null, tier: data.tier ?? null, diff --git a/src/commands/code/auth-sync.ts b/src/commands/code/auth-sync.ts index a30e71b..5f268f9 100644 --- a/src/commands/code/auth-sync.ts +++ b/src/commands/code/auth-sync.ts @@ -1,16 +1,8 @@ -import type { - ApiKeyServicePort, - AuthServicePort, - SeatStatusPort, -} from './ports/auth-services.js'; +import type { ApiKeyServicePort, AuthServicePort, SeatStatusPort } from './ports/auth-services.js'; import type { FileStore } from './ports/file-store.js'; import type { Prompter } from './ports/prompter.js'; -import { - decodeJwtPayload, - extractJwtExpiresAt, - isTokenExpired, -} from '../../auth/jwt.js'; +import { decodeJwtPayload, extractJwtExpiresAt, isTokenExpired } from '../../auth/jwt.js'; import { logger } from '../../utils/logger.js'; import { FatalError } from './errors.js'; import { getOpencodeAuthPath, getPiAuthPath } from './xdg-paths.js'; @@ -293,7 +285,7 @@ async function handleHasSeat( homeDir: string, tool: 'opencode' | 'pi', cliAuth: CliAuth, - tier: string | null, + tier: null | string, ): Promise { const tierLabel = (tier && TIER_LABELS[tier]) || 'Berget'; const method = await prompter.select<'api_key' | 'subscription'>({ diff --git a/src/commands/code/ports/auth-services.ts b/src/commands/code/ports/auth-services.ts index 78368a4..2691d28 100644 --- a/src/commands/code/ports/auth-services.ts +++ b/src/commands/code/ports/auth-services.ts @@ -14,8 +14,8 @@ export interface AuthServicePort { } export interface SeatStatus { - seatId: number | null; - tier: string | null; + seatId: null | number; + tier: null | string; } /** @@ -26,5 +26,5 @@ export interface SeatStatus { * caller can fall back to a warn-and-continue path. */ export interface SeatStatusPort { - fetchSeatStatus(accessToken: string): Promise; + fetchSeatStatus(accessToken: string): Promise; }