// Real entitlement + metering enforcement for the AI Assistant's `server` // class (docs/AI-ASSISTANT-CONCEPT.md §9/§10 — per-seat licensing, a // metering ledger that doubles as the billing record). // // Deliberately scoped to `server` only, not `local`/`public`, per the // 2026-08-05 decisions: `local` ships free (never reaches a server this app // controls, so it can't be metered — see the doc's own §9 reasoning) and // `public` is explicitly unmonitored for now. `server` is the one class that // (a) proxies through this app's own backend (see app/api/ai/server/*) and // (b) has a real marginal cost (shared GPU time) worth gating — so it is the // one place enforcement is both possible and worth building tonight. // // Persistence follows the existing admin state-dir convention // (lib/admin/paths.ts): STATE, not CONFIG, because this is runtime-mutated // data (seat assignments, usage), not operator-authored config. import { readFile, writeFile, rename, appendFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { getStatePath, ensureStateDir } from '@/lib/admin/paths'; import { logger } from '@/lib/logger'; export interface AiEntitlementState { subject: 'tenant' | 'user'; tier: 'base' | 'standard' | 'pro'; /** Total seats licensed. 0 = server class entirely unlicensed (default). */ seatsTotal: number; /** Usernames who have consumed a seat (first successful use assigns one, * matching real per-seat licensing — not deallocated by idling). */ assignedTo: string[]; } export interface EntitlementCheck { allowed: boolean; reason?: string; /** True the moment this call consumed a previously-unassigned seat. */ seatJustAssigned?: boolean; } export interface MeteringEntry { timestamp: string; username: string; model: string; /** Ollama reports these as prompt_eval_count / eval_count. */ promptTokens: number; completionTokens: number; latencyMs: number; } const STATE_FILE = 'ai-entitlement.json'; const LEDGER_FILE = 'ai-metering.jsonl'; const DEFAULT_STATE: AiEntitlementState = { subject: 'tenant', tier: 'base', seatsTotal: Number.parseInt(process.env.AI_SERVER_SEAT_TOTAL ?? '0', 10) || 0, assignedTo: [], }; // Stash on globalThis like config-manager.ts — HMR/dev re-evaluates this // module, and in-memory seat state must survive that or every hot reload // would silently re-grant seats. const SINGLETON_KEY = Symbol.for('vncmail.ai.entitlement'); type GlobalWithState = typeof globalThis & { [SINGLETON_KEY]?: Promise | undefined }; async function readState(): Promise { try { const raw = await readFile(getStatePath(STATE_FILE), 'utf-8'); return { ...DEFAULT_STATE, ...JSON.parse(raw) }; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { logger.warn('ai-entitlement: failed to read state, using defaults', { error: error instanceof Error ? error.message : String(error), }); } return { ...DEFAULT_STATE }; } } async function writeState(state: AiEntitlementState): Promise { await ensureStateDir(); const target = getStatePath(STATE_FILE); const tmp = target + '.tmp'; await writeFile(tmp, JSON.stringify(state, null, 2), 'utf-8'); await rename(tmp, target); } let cached: AiEntitlementState | null = null; async function loadCached(): Promise { if (cached) return cached; const g = globalThis as GlobalWithState; if (!g[SINGLETON_KEY]) g[SINGLETON_KEY] = readState(); cached = await g[SINGLETON_KEY]; return cached; } /** * The real enforcement point (doc §10 point 2): re-validated on every call, * never trusts anything the client sent. Auto-assigns a seat on first use * when seats remain — that's what "per-seat" means for a subject that * hasn't been explicitly provisioned by an admin yet. */ export async function checkAndAssignSeat(username: string): Promise { const state = await loadCached(); if (state.assignedTo.includes(username)) { return { allowed: true }; } if (state.assignedTo.length >= state.seatsTotal) { return { allowed: false, reason: state.seatsTotal === 0 ? 'The server-hosted AI class has no licensed seats configured.' : `All ${state.seatsTotal} licensed seat(s) are already assigned to other users.`, }; } const next: AiEntitlementState = { ...state, assignedTo: [...state.assignedTo, username] }; await writeState(next); cached = next; const g = globalThis as GlobalWithState; g[SINGLETON_KEY] = Promise.resolve(next); return { allowed: true, seatJustAssigned: true }; } /** The metering write IS the billing record — see module header. Append-only, * never rewritten, so it stays valid as an audit trail even if this process * crashes mid-write (worst case: one truncated trailing line). */ export async function recordUsage(entry: MeteringEntry): Promise { await ensureStateDir(); await appendFile(getStatePath(LEDGER_FILE), JSON.stringify(entry) + '\n', 'utf-8'); } export async function getEntitlementState(): Promise { return loadCached(); } export async function setSeatTotal(total: number): Promise { const state = await loadCached(); const next: AiEntitlementState = { ...state, seatsTotal: Math.max(0, Math.trunc(total)) }; await writeState(next); cached = next; (globalThis as GlobalWithState)[SINGLETON_KEY] = Promise.resolve(next); return next; } export async function revokeSeat(username: string): Promise { const state = await loadCached(); const next: AiEntitlementState = { ...state, assignedTo: state.assignedTo.filter((u) => u !== username) }; await writeState(next); cached = next; (globalThis as GlobalWithState)[SINGLETON_KEY] = Promise.resolve(next); return next; } /** Read-only summary, no PII beyond usernames already visible to any admin. */ export async function readMeteringLedger(limit = 200): Promise { const path = getStatePath(LEDGER_FILE); if (!existsSync(path)) return []; const raw = await readFile(path, 'utf-8'); const lines = raw.trim().split('\n').filter(Boolean); return lines.slice(-limit).map((line) => JSON.parse(line) as MeteringEntry); }