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.
93 lines
3.4 KiB
TypeScript
93 lines
3.4 KiB
TypeScript
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
// Real end-to-end seat assignment against the real Ollama was verified live
|
|
// (see the commit this test ships with); this covers the rejection branch,
|
|
// which is deterministic and cheaper to prove with a unit test than another
|
|
// live round trip.
|
|
|
|
describe('lib/ai/entitlement', () => {
|
|
let stateDir: string;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
stateDir = await mkdtemp(path.join(tmpdir(), 'ai-entitlement-test-'));
|
|
process.env.ADMIN_STATE_DIR = stateDir;
|
|
delete process.env.AI_SERVER_SEAT_TOTAL;
|
|
// Each test needs a fresh globalThis singleton, not just a fresh module -
|
|
// the module stashes cached state on globalThis specifically to survive
|
|
// HMR, so resetModules() alone doesn't clear it.
|
|
delete (globalThis as Record<symbol, unknown>)[Symbol.for('vncmail.ai.entitlement')];
|
|
});
|
|
|
|
afterEach(async () => {
|
|
delete process.env.ADMIN_STATE_DIR;
|
|
await rm(stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('assigns a seat on first use and allows the same user again', async () => {
|
|
const { checkAndAssignSeat, setSeatTotal } = await import('../entitlement');
|
|
await setSeatTotal(1);
|
|
|
|
const first = await checkAndAssignSeat('alice@example.com');
|
|
expect(first).toEqual({ allowed: true, seatJustAssigned: true });
|
|
|
|
const second = await checkAndAssignSeat('alice@example.com');
|
|
expect(second).toEqual({ allowed: true });
|
|
});
|
|
|
|
it('rejects a new user once all seats are assigned', async () => {
|
|
const { checkAndAssignSeat, setSeatTotal } = await import('../entitlement');
|
|
await setSeatTotal(1);
|
|
await checkAndAssignSeat('alice@example.com');
|
|
|
|
const rejected = await checkAndAssignSeat('bob@example.com');
|
|
expect(rejected.allowed).toBe(false);
|
|
expect(rejected.reason).toMatch(/already assigned/i);
|
|
});
|
|
|
|
it('rejects everyone when no seats are configured', async () => {
|
|
const { checkAndAssignSeat } = await import('../entitlement');
|
|
const result = await checkAndAssignSeat('anyone@example.com');
|
|
expect(result.allowed).toBe(false);
|
|
expect(result.reason).toMatch(/no licensed seats/i);
|
|
});
|
|
|
|
it('revoking a seat frees it for someone else', async () => {
|
|
const { checkAndAssignSeat, setSeatTotal, revokeSeat } = await import('../entitlement');
|
|
await setSeatTotal(1);
|
|
await checkAndAssignSeat('alice@example.com');
|
|
await revokeSeat('alice@example.com');
|
|
|
|
const result = await checkAndAssignSeat('bob@example.com');
|
|
expect(result).toEqual({ allowed: true, seatJustAssigned: true });
|
|
});
|
|
|
|
it('persists usage to the metering ledger, append-only', async () => {
|
|
const { recordUsage, readMeteringLedger } = await import('../entitlement');
|
|
await recordUsage({
|
|
timestamp: new Date(0).toISOString(),
|
|
username: 'alice@example.com',
|
|
model: 'qwen2.5:32b',
|
|
promptTokens: 10,
|
|
completionTokens: 5,
|
|
latencyMs: 123,
|
|
});
|
|
await recordUsage({
|
|
timestamp: new Date(0).toISOString(),
|
|
username: 'alice@example.com',
|
|
model: 'qwen2.5:32b',
|
|
promptTokens: 8,
|
|
completionTokens: 3,
|
|
latencyMs: 90,
|
|
});
|
|
|
|
const ledger = await readMeteringLedger();
|
|
expect(ledger).toHaveLength(2);
|
|
expect(ledger[0].promptTokens).toBe(10);
|
|
expect(ledger[1].promptTokens).toBe(8);
|
|
});
|
|
});
|