feat(ai): multi-key BYOK, real server class, real entitlement enforcement

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.
This commit is contained in:
Bernd Rodler
2026-08-06 00:07:56 +02:00
parent bde8455df5
commit dda7adf565
11 changed files with 821 additions and 122 deletions
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { getEntitlementState, setSeatTotal, revokeSeat, readMeteringLedger } from '@/lib/ai/entitlement';
export const runtime = 'nodejs';
/**
* Admin-only data endpoints for the `server` AI class's real entitlement
* enforcement (lib/ai/entitlement.ts). This is the data plumbing only — the
* visual admin console (docs/AI-ASSISTANT-CONCEPT.md §6) is a separate,
* not-yet-built UI on top of these same endpoints.
*/
export async function GET(request: NextRequest) {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
try {
const [state, ledger] = await Promise.all([getEntitlementState(), readMeteringLedger()]);
return NextResponse.json({ ...state, recentUsage: ledger }, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
logger.error('ai entitlement read error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
let body: { seatsTotal?: unknown; revokeUsername?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
try {
if (typeof body.seatsTotal === 'number') {
const state = await setSeatTotal(body.seatsTotal);
await auditLog('ai.entitlement.seats_total', { seatsTotal: state.seatsTotal }, ip);
return NextResponse.json(state);
}
if (typeof body.revokeUsername === 'string' && body.revokeUsername) {
const state = await revokeSeat(body.revokeUsername);
await auditLog('ai.entitlement.revoke_seat', { username: body.revokeUsername }, ip);
return NextResponse.json(state);
}
return NextResponse.json({ error: 'seatsTotal or revokeUsername is required' }, { status: 400 });
} catch (error) {
logger.error('ai entitlement update error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+10 -6
View File
@@ -6,20 +6,24 @@ import { DEFAULT_AI_ENTITLEMENT, type AiPolicy } from '@/lib/ai/types';
/**
* GET /api/ai/policy - AI Assistant policy (NOT admin-protected - users read this)
*
* P0 stub (docs/AI-ASSISTANT-CONCEPT.md §12): proves the client<->server
* policy-fetch plumbing end-to-end with no provider ever called. `enabled`
* mirrors the admin FeatureGates toggle; entitlement is hardcoded unlicensed
* until P2 wires a real seats/billing backend (§9) - there is no provider
* class to grant yet regardless of what an entitlement record might say.
* `enabled` mirrors the admin FeatureGates toggle. `entitlement.classes`
* reflects real configuration, not a hardcoded guess: `server` only appears
* when AI_SERVER_BASE_URL is actually set (app/api/ai/server/* would 503
* otherwise) - this is enforcement point 1 (docs §10), cosmetic-only, the
* client hiding what it can't use; the real gate is checkAndAssignSeat() on
* every /api/ai/server/chat call, not this list.
*/
export async function GET() {
try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const classes = [...DEFAULT_AI_ENTITLEMENT.classes];
if (process.env.AI_SERVER_BASE_URL) classes.push('server');
const aiPolicy: AiPolicy = {
enabled: policy.features.aiAssistantEnabled,
entitlement: { ...DEFAULT_AI_ENTITLEMENT },
entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes },
publicConsentVersion: null,
};
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { checkAndAssignSeat, recordUsage } from '@/lib/ai/entitlement';
import { logger } from '@/lib/logger';
export const runtime = 'nodejs';
const MAX_BODY_BYTES = 200 * 1024;
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
interface OllamaChatResponse {
message?: { content?: string };
prompt_eval_count?: number;
eval_count?: number;
}
/**
* POST /api/ai/server/chat — the one real enforcement chokepoint for the
* `server` AI class (docs/AI-ASSISTANT-CONCEPT.md §10 point 2: "re-validates
* ... entitlement against live state; rejects on mismatch ... never trusts
* the client"). Every call re-checks the seat; nothing here is cosmetic.
*
* Retrieval already happened client-side (the same /api/offline/search leg
* `local`/`public` use) — this route receives the already-built prompt
* messages and only proxies the model call + records the metering entry
* that IS the billing record (lib/ai/entitlement.ts).
*/
export async function POST(request: NextRequest) {
const auth = await getStalwartCredentials(request);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
const seat = await checkAndAssignSeat(auth.username);
if (!seat.allowed) {
return NextResponse.json({ error: seat.reason ?? 'not entitled' }, { status: 402 });
}
const rawBody = await request.text();
if (rawBody.length > MAX_BODY_BYTES) {
return NextResponse.json({ error: 'request too large' }, { status: 413 });
}
let body: { model?: unknown; messages?: unknown };
try {
body = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
const model = typeof body.model === 'string' ? body.model : '';
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
if (!model || !messages || messages.length === 0) {
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
}
const baseUrl = process.env.AI_SERVER_BASE_URL;
if (!baseUrl) {
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
}
const startedAt = Date.now();
try {
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages, stream: false }),
});
if (!res.ok) {
return NextResponse.json({ error: `AI server returned ${res.status}` }, { status: 502 });
}
const data = (await res.json()) as OllamaChatResponse;
const content = data.message?.content;
if (!content) {
return NextResponse.json({ error: 'AI server returned no message content' }, { status: 502 });
}
await recordUsage({
timestamp: new Date().toISOString(),
username: auth.username,
model,
promptTokens: data.prompt_eval_count ?? 0,
completionTokens: data.eval_count ?? 0,
latencyMs: Date.now() - startedAt,
});
return NextResponse.json({ answer: content, seatJustAssigned: seat.seatJustAssigned === true });
} catch (cause) {
logger.error('ai server chat failed', { error: cause instanceof Error ? cause.message : String(cause) });
return NextResponse.json({ error: 'AI server unreachable' }, { status: 502 });
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
export const runtime = 'nodejs';
/**
* GET /api/ai/server/models — list models on the centrally-hosted `server`
* class runtime (docs/AI-ASSISTANT-CONCEPT.md §2.1: "the same self-hosted
* open-weight model stack as `local`... running on VNC's own infrastructure
* instead of the user's laptop"). Tonight, `AI_SERVER_BASE_URL` stands in for
* that infra with the Ollama already running on this developer's Mac — see
* the module comment in lib/ai/entitlement.ts. Swapping to the real
* EU/CH-hosted instance tomorrow is a config change, not a rewrite.
*
* Listing models is not a billable action (doc §10 point 1 — cosmetic), so
* this only requires a valid session, not a seat.
*/
export async function GET(request: NextRequest) {
const auth = await getStalwartCredentials(request);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
const baseUrl = process.env.AI_SERVER_BASE_URL;
if (!baseUrl) {
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
}
try {
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/tags`);
if (!res.ok) {
return NextResponse.json({ error: `upstream returned ${res.status}` }, { status: 502 });
}
const body = (await res.json()) as { models?: Array<{ name: string }> };
return NextResponse.json({ models: (body.models ?? []).map((m) => m.name).filter(Boolean) });
} catch (cause) {
return NextResponse.json(
{ error: cause instanceof Error ? cause.message : 'AI server unreachable' },
{ status: 502 },
);
}
}