diff --git a/src/auth/__tests__/jwt.test.ts b/src/auth/__tests__/jwt.test.ts index df54719..8d6b993 100644 --- a/src/auth/__tests__/jwt.test.ts +++ b/src/auth/__tests__/jwt.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - decodeJwtPayload, - extractJwtExpiresAt, - hasBergetCodeSeat, - isTokenExpired, -} from '../jwt.js'; +import { decodeJwtPayload, extractJwtExpiresAt, isTokenExpired } from '../jwt.js'; function base64urlEncode(data: string): string { return Buffer.from(data).toString('base64url'); @@ -88,57 +83,3 @@ describe('isTokenExpired', () => { expect(isTokenExpired(farFuture)).toBe(false); }); }); - -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..46603bb --- /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 '../../commands/code/adapters/seat-status.js'; + +const BASE = 'https://api.berget.ai'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +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({ + json: async () => ({ authenticated: true, seatId: 168, tier: 'seat_plan_pro' }), + ok: true, + }), + ); + 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({ + json: async () => ({ authenticated: true, seatId: null, tier: null }), + ok: true, + }), + ); + 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({ json: async () => ({}), ok: false, status: 401 }), + ); + 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 () => { + vi.stubEnv('BERGET_API_URL', 'https://api.stage.berget.ai'); + 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/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/commands/code/__tests__/auth-sync.test.ts b/src/commands/code/__tests__/auth-sync.test.ts index e449fbc..7365e31 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, @@ -16,6 +16,7 @@ import { FakeApiKeyService } from './fake-api-key-service.js'; import { FakeAuthService } from './fake-auth-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', @@ -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,45 @@ 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([]); + + 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 +556,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..f9c71a8 --- /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: null | SeatStatus) {} + + async fetchSeatStatus(): Promise { + return this.result; + } +} 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/adapters/seat-status.ts b/src/commands/code/adapters/seat-status.ts new file mode 100644 index 0000000..631d949 --- /dev/null +++ b/src/commands/code/adapters/seat-status.ts @@ -0,0 +1,36 @@ +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 + * 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, 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 = 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?: null | number; tier?: null | string }; + 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/auth-sync.ts b/src/commands/code/auth-sync.ts index 01a2883..5f268f9 100644 --- a/src/commands/code/auth-sync.ts +++ b/src/commands/code/auth-sync.ts @@ -1,13 +1,8 @@ -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 { 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'; @@ -18,6 +13,7 @@ export interface AuthDeps { files: FileStore; homeDir: string; prompter: Prompter; + seatStatusService: SeatStatusPort; } export interface AuthResult { @@ -43,7 +39,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 { @@ -75,13 +71,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.seatId != null || seatStatus.tier != null) { + return handleHasSeat(prompter, apiKeyService, files, homeDir, tool, cliAuth, seatStatus.tier); } return handleNoSeat(prompter, apiKeyService, files, homeDir, tool); @@ -266,6 +271,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 +285,13 @@ async function handleHasSeat( homeDir: string, tool: 'opencode' | 'pi', cliAuth: CliAuth, + tier: null | string, ): Promise { + const tierLabel = (tier && 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 +335,7 @@ async function handleNoSeat( return { authenticated: false }; } -async function handleUndecodableJwt( +async function handleUnverifiedAuth( prompter: Prompter, files: FileStore, homeDir: string, @@ -338,7 +352,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..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,6 +10,7 @@ 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 { 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'; @@ -36,6 +37,7 @@ export interface WizardDeps { homeDir: string; isTty?: boolean; prompter: Prompter; + seatStatusService: SeatStatusPort; } export async function executeInitCommand(deps: WizardDeps): Promise { @@ -53,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( @@ -95,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, ); @@ -129,6 +141,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..2691d28 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: null | number; + tier: null | string; +} + +/** + * 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; +}