Three pieces built together tonight since they're naturally linked (the
server-class proxy is the real entitlement enforcement chokepoint):
1. Multi-key BYOK (public class): several named provider profiles
(name/baseUrl/model), each with its own key in lib/ai/key-store.ts
(keyed by profile id, not a single fixed 'public' slot). The "Try it"
pane lets you pick which saved profile answers each question - not one
fixed default.
2. `server` class, real: app/api/ai/server/{models,chat} proxy through
this app's own backend to AI_SERVER_BASE_URL - same-origin from the
browser, no CORS/OLLAMA_ORIGINS story at all, standing in tonight for
VNC's EU/CH-hosted infra with the real Ollama on this Mac (swapping to
the real instance tomorrow is a config change).
3. Real entitlement enforcement (lib/ai/entitlement.ts), scoped to `server`
only (not local/public, per the 2026-08-05 decisions): checkAndAssignSeat()
re-validates on every /api/ai/server/chat call - first use auto-assigns a
seat if any remain, further calls from an unlicensed user get a 402 with
a specific reason. recordUsage() appends to an append-only metering
ledger (timestamp/user/model/tokens/latency) that IS the billing record.
Admin data endpoints at /api/admin/ai/entitlement (seat total, revoke) -
the visual admin console is a separate, not-yet-built task.
Two real bugs found and fixed during verification, not just claimed fixed:
- /api/ai/policy never actually added 'server' to entitlement.classes even
when AI_SERVER_BASE_URL was set (only the type comment was updated) - the
Server radio option silently never appeared until this was caught live.
- The new routes used readStalwartAuthContext(0) (hardcoded slot, SSO/reauth-
specific) instead of getStalwartCredentials() (the general multi-slot
session resolver every other authenticated route uses) - reachable but
wrong, and would have hidden a real auth gap behind "works on my slot".
Verified end-to-end for real: built + ran the actual server, logged in via
the real (non-demo) auth flow, selected Server, listed the real Ollama
models through the proxy, asked "Reply with exactly the words: SERVER CLASS
WORKS" and got back exactly that - plus confirmed on disk (not just in the
UI) that data/admin-state/ai-entitlement.json recorded the seat assignment
and ai-metering.jsonl recorded real prompt/completion token counts and
latency from the actual model call. Rejection-path logic (seat limit
reached, zero seats configured, revocation) covered by 5 new unit tests
rather than a second live round trip. Full suite: typecheck clean, lint
clean, translations 48/48, production build succeeds.
163 lines
6.2 KiB
TypeScript
163 lines
6.2 KiB
TypeScript
// 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<AiEntitlementState> | undefined };
|
|
|
|
async function readState(): Promise<AiEntitlementState> {
|
|
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<void> {
|
|
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<AiEntitlementState> {
|
|
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<EntitlementCheck> {
|
|
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<void> {
|
|
await ensureStateDir();
|
|
await appendFile(getStatePath(LEDGER_FILE), JSON.stringify(entry) + '\n', 'utf-8');
|
|
}
|
|
|
|
export async function getEntitlementState(): Promise<AiEntitlementState> {
|
|
return loadCached();
|
|
}
|
|
|
|
export async function setSeatTotal(total: number): Promise<AiEntitlementState> {
|
|
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<AiEntitlementState> {
|
|
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<MeteringEntry[]> {
|
|
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);
|
|
}
|