v1.1.0:8 — admin-gate whole-DB routes + AI custom-URL providers; SSRF guard
Multi-user authorization hardening from a full security evaluation (EVALUATION.md):
- P0: /api/settings/{export,import}-db are now admin-only. Previously any signed-in user could download the whole instance DB (all bcrypt hashes + plaintext AI keys) or replace it wholesale. Per-user CSV export/import stays open.
- AI custom-URL providers (Ollama, OpenAI-compatible) are now admin-only, and every server fetch to a user-supplied URL passes through assertSafeProviderUrl (blocks link-local/cloud-metadata; private LAN allowed by design). Fixed-URL cloud providers stay per-user. Removed the dead legacy /api/ai/config route.
- Dev: fixed broken quick-start (added npm run create-admin; rewrote README; dropped dead CLAUDE_API_KEY) and the export-db 0-byte path resolution (resolveDatabasePath now matches Prisma).
ExVer bumped to 1.1.0:8 (no schema/data migration). Tests 197 pass, build green, tsc clean.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Database
|
||||
DATABASE_URL=file:./data/app.db
|
||||
|
||||
# API Keys
|
||||
CLAUDE_API_KEY=your_claude_api_key_here
|
||||
# AI provider API keys are NOT configured here — each user sets their own
|
||||
# key per provider in the app (Settings → AI), stored in the database.
|
||||
|
||||
+12
-3
@@ -11,16 +11,25 @@ npm install
|
||||
# Set up the database
|
||||
npx prisma db push
|
||||
|
||||
# Seed with exercises and default user
|
||||
# Seed the InstanceSettings singleton
|
||||
npm run db:seed
|
||||
|
||||
# Create the first admin (fresh installs ship with NO users — see below).
|
||||
# Use a real-looking email; "admin@local" is rejected (no TLD).
|
||||
npm run create-admin -- you@example.com yourpassword "Your Name"
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
Open [http://localhost:3000](http://localhost:3000) and log in with the
|
||||
email/password you just created.
|
||||
|
||||
**Default login:** `admin@local` / `workout123`
|
||||
**No default account.** Fresh installs ship with zero users on purpose, so there
|
||||
are no default credentials to forget and leak. In production (StartOS) the
|
||||
operator creates the first admin via the **Actions → Set admin credentials**
|
||||
action; locally, `npm run create-admin` is the equivalent. Once an admin exists,
|
||||
additional users sign up at `/auth/signup` (if sign-ups are enabled in Settings).
|
||||
|
||||
## Access from Other Devices
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
/**
|
||||
* GET /api/ai/config — read this user's AI provider config.
|
||||
* API key is NOT returned in plaintext (only
|
||||
* a "configured: true|false" flag) so it
|
||||
* doesn't leak via Settings page reload.
|
||||
* POST /api/ai/config — update. Pass null/empty to clear a field.
|
||||
*/
|
||||
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const prefs = await prisma.userPreferences.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { aiProvider: true, aiModel: true, aiBaseUrl: true, aiApiKey: true },
|
||||
});
|
||||
return NextResponse.json({
|
||||
aiProvider: prefs?.aiProvider ?? null,
|
||||
aiModel: prefs?.aiModel ?? null,
|
||||
aiBaseUrl: prefs?.aiBaseUrl ?? null,
|
||||
aiKeyConfigured: !!prefs?.aiApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
const bodySchema = z.object({
|
||||
aiProvider: z
|
||||
.enum(['claude', 'openai', 'openai-compatible', 'gemini', 'ollama'])
|
||||
.nullable()
|
||||
.optional(),
|
||||
aiModel: z.string().nullable().optional(),
|
||||
aiBaseUrl: z.string().url().nullable().optional().or(z.literal('')),
|
||||
aiApiKey: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid body', details: parsed.error.errors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Empty string -> null (UI sometimes sends "")
|
||||
const norm = (v: string | null | undefined) =>
|
||||
v === '' || v == null ? null : v;
|
||||
|
||||
const data: Record<string, string | null> = {};
|
||||
if (parsed.data.aiProvider !== undefined)
|
||||
data.aiProvider = parsed.data.aiProvider ?? null;
|
||||
if (parsed.data.aiModel !== undefined) data.aiModel = norm(parsed.data.aiModel);
|
||||
if (parsed.data.aiBaseUrl !== undefined)
|
||||
data.aiBaseUrl = norm(parsed.data.aiBaseUrl);
|
||||
if (parsed.data.aiApiKey !== undefined)
|
||||
data.aiApiKey = norm(parsed.data.aiApiKey);
|
||||
|
||||
// Make sure the prefs row exists.
|
||||
await prisma.userPreferences.upsert({
|
||||
where: { userId: user.id },
|
||||
update: data,
|
||||
create: {
|
||||
userId: user.id,
|
||||
theme: 'system',
|
||||
defaultWeightUnit: 'lbs',
|
||||
defaultRestSeconds: 90,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { activate } from '@/lib/ai/activateConfig';
|
||||
import { isCustomUrlProvider } from '@/lib/ai/providers';
|
||||
|
||||
/**
|
||||
* GET /api/ai/configs/[id] Single config (apiKey redacted).
|
||||
@@ -72,6 +73,21 @@ export async function PATCH(
|
||||
});
|
||||
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
|
||||
// Admin-only custom-URL surface (see configs POST). Blocks a non-admin from
|
||||
// setting a base URL, or editing a custom-URL provider config at all.
|
||||
if (
|
||||
!user.isAdmin &&
|
||||
(parsed.data.baseUrl || isCustomUrlProvider(existing.provider))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Only an admin can configure providers with a custom base URL (Ollama / OpenAI-compatible).',
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const data: Record<string, string | null> = {};
|
||||
if (parsed.data.name !== undefined) data.name = parsed.data.name;
|
||||
if (parsed.data.model !== undefined) data.model = parsed.data.model;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { activate } from '@/lib/ai/activateConfig';
|
||||
import { isCustomUrlProvider } from '@/lib/ai/providers';
|
||||
|
||||
/**
|
||||
* v1.1.0:4 — Multi-config CRUD.
|
||||
@@ -80,6 +81,20 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { name, provider, model, baseUrl, apiKey, setActive } = parsed.data;
|
||||
|
||||
// Custom-URL providers (Ollama / OpenAI-compatible) are admin-only — a
|
||||
// non-admin pointing the server at an arbitrary URL is the SSRF actor
|
||||
// vector. Fixed-URL cloud providers stay per-user.
|
||||
if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Only an admin can configure providers with a custom base URL (Ollama / OpenAI-compatible).',
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const profile = await prisma.aIConfigProfile.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { assertSafeProviderUrl } from '@/lib/ai/safeUrl';
|
||||
|
||||
/**
|
||||
* GET /api/ai/ollama/models?baseUrl=...
|
||||
@@ -29,6 +30,10 @@ const DEFAULT_CANDIDATES = [
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
|
||||
// Probing Ollama URLs is the admin-only custom-URL surface (EVALUATION.md
|
||||
// P1) — a non-admin shouldn't be able to fingerprint the local network.
|
||||
if (!user.isAdmin)
|
||||
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const url = new URL(request.url);
|
||||
const explicit = url.searchParams.get('baseUrl');
|
||||
@@ -54,10 +59,21 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
async function probe(baseUrl: string) {
|
||||
const t0 = Date.now();
|
||||
const url = baseUrl.replace(/\/$/, '') + '/api/tags';
|
||||
try {
|
||||
await assertSafeProviderUrl(url);
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false as const,
|
||||
baseUrl,
|
||||
error: (e as Error).message,
|
||||
ms: Date.now() - t0,
|
||||
};
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(baseUrl.replace(/\/$/, '') + '/api/tags', {
|
||||
const res = await fetch(url, {
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getProvider } from '@/lib/ai/providers';
|
||||
import { getProvider, isCustomUrlProvider } from '@/lib/ai/providers';
|
||||
|
||||
/**
|
||||
* POST /api/ai/test
|
||||
@@ -112,6 +112,19 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Testing an arbitrary base URL is the same SSRF surface as configuring
|
||||
// one — admin-only. Non-admins may only test fixed-URL cloud providers.
|
||||
if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
'Only an admin can test providers with a custom base URL (Ollama / OpenAI-compatible).',
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const providerImpl = getProvider(provider);
|
||||
if (!providerImpl) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -16,6 +16,11 @@ export async function GET() {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Whole-instance operation: the file contains every user's data and
|
||||
// password hashes. Admin-only — regular users use /api/me/export.
|
||||
if (!user.isAdmin) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const dbPath = resolveDatabasePath();
|
||||
const data = await readFile(dbPath);
|
||||
|
||||
@@ -18,6 +18,11 @@ export async function POST(request: NextRequest) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Replaces the entire instance database — admin-only. Without this a
|
||||
// regular user could overwrite the DB to mint themselves an admin.
|
||||
if (!user.isAdmin) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("database") as File | null;
|
||||
|
||||
@@ -33,7 +33,7 @@ export default async function SettingsPage() {
|
||||
<div id="general"><SettingsForm user={user} /></div>
|
||||
<div id="password"><ChangePasswordForm /></div>
|
||||
<div id="sessions"><SessionsList /></div>
|
||||
<div id="ai"><AIIntegration /></div>
|
||||
<div id="ai"><AIIntegration isAdmin={user.isAdmin} /></div>
|
||||
<div id="data"><ExportMyData /></div>
|
||||
{user.isAdmin && instanceSettings && (
|
||||
<div id="instance">
|
||||
|
||||
@@ -15,6 +15,9 @@ import { MODEL_MENU } from '@/lib/ai/pricing';
|
||||
* dropdown of installed models when reachable.
|
||||
*/
|
||||
|
||||
// UI-side provider metadata. `requiresUrl` mirrors the `requiresBaseUrl` flag
|
||||
// on the server providers (lib/ai/providers); keep the two in sync when adding
|
||||
// a provider. `requiresUrl: true` ⇒ custom-URL ⇒ admin-only (see configs API).
|
||||
const PROVIDERS = [
|
||||
{ id: 'claude', label: 'Anthropic Claude', requiresKey: true, requiresUrl: false },
|
||||
{ id: 'openai', label: 'OpenAI', requiresKey: true, requiresUrl: false },
|
||||
@@ -44,7 +47,7 @@ type TestResult =
|
||||
| { ok: true; sample: string; tokensIn?: number; tokensOut?: number; ms: number }
|
||||
| { ok: false; error: string; ms?: number };
|
||||
|
||||
export default function AIIntegration() {
|
||||
export default function AIIntegration({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [configs, setConfigs] = useState<SavedConfig[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -121,6 +124,7 @@ export default function AIIntegration() {
|
||||
<ConfigRow
|
||||
key={c.id}
|
||||
cfg={c}
|
||||
isAdmin={isAdmin}
|
||||
isActive={c.id === activeId}
|
||||
isEditing={editingId === c.id}
|
||||
onActivate={() => handleActivate(c.id)}
|
||||
@@ -137,6 +141,7 @@ export default function AIIntegration() {
|
||||
|
||||
{showForm ? (
|
||||
<ConfigForm
|
||||
isAdmin={isAdmin}
|
||||
onCancel={() => setShowForm(false)}
|
||||
onCreated={() => {
|
||||
setShowForm(false);
|
||||
@@ -167,6 +172,7 @@ export default function AIIntegration() {
|
||||
*/
|
||||
function ConfigRow({
|
||||
cfg,
|
||||
isAdmin,
|
||||
isActive,
|
||||
isEditing,
|
||||
onActivate,
|
||||
@@ -175,6 +181,7 @@ function ConfigRow({
|
||||
onSaved,
|
||||
}: {
|
||||
cfg: SavedConfig;
|
||||
isAdmin: boolean;
|
||||
isActive: boolean;
|
||||
isEditing: boolean;
|
||||
onActivate: () => void;
|
||||
@@ -312,6 +319,7 @@ function ConfigRow({
|
||||
{isEditing && (
|
||||
<div className="border-t border-zinc-800 pt-3">
|
||||
<ConfigForm
|
||||
isAdmin={isAdmin}
|
||||
initial={cfg}
|
||||
onCancel={onEdit}
|
||||
onCreated={onSaved}
|
||||
@@ -325,6 +333,8 @@ function ConfigRow({
|
||||
interface ConfigFormProps {
|
||||
/** When set: editing this saved config (PATCH). Otherwise: creating new (POST). */
|
||||
initial?: SavedConfig;
|
||||
/** Custom-URL providers (Ollama / OpenAI-compatible) are admin-only. */
|
||||
isAdmin: boolean;
|
||||
onCancel: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
@@ -343,8 +353,13 @@ interface ConfigFormProps {
|
||||
* tests the in-progress form values without saving — handy for
|
||||
* checking a key before committing.
|
||||
*/
|
||||
function ConfigForm({ initial, onCancel, onCreated }: ConfigFormProps) {
|
||||
function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps) {
|
||||
const isEdit = !!initial;
|
||||
// Non-admins can't configure custom-URL providers — hide them from the
|
||||
// dropdown (the server enforces this too; see app/api/ai/configs).
|
||||
const availableProviders = isAdmin
|
||||
? PROVIDERS
|
||||
: PROVIDERS.filter((p) => !p.requiresUrl);
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [provider, setProvider] = useState<ProviderId>(initial?.provider ?? 'claude');
|
||||
const [model, setModel] = useState(initial?.model ?? '');
|
||||
@@ -500,7 +515,7 @@ function ConfigForm({ initial, onCancel, onCreated }: ConfigFormProps) {
|
||||
className={inputClass}
|
||||
disabled={isEdit}
|
||||
>
|
||||
{PROVIDERS.map((p) => (
|
||||
{availableProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
|
||||
@@ -191,15 +191,17 @@ export default function SettingsForm({ user }: { user: User }) {
|
||||
</button>
|
||||
|
||||
{/* Database Import Section */}
|
||||
<DatabaseExport />
|
||||
<DatabaseExport isAdmin={user.isAdmin} />
|
||||
<WorkoutCsvImportShortcut />
|
||||
<DatabaseImport />
|
||||
{/* Whole-instance DB replace is admin-only (it overwrites every
|
||||
user's data); the per-user CSV import above stays for everyone. */}
|
||||
{user.isAdmin && <DatabaseImport />}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Database Export Component ----------
|
||||
function DatabaseExport() {
|
||||
function DatabaseExport({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [exportingDb, setExportingDb] = useState(false);
|
||||
const [exportingCsv, setExportingCsv] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
@@ -246,7 +248,9 @@ function DatabaseExport() {
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-1">Export Backups</h2>
|
||||
<p className="text-sm text-zinc-500 mb-4">
|
||||
Download a full database backup or a CSV export of workout logs.
|
||||
{isAdmin
|
||||
? "Download a full database backup or a CSV export of workout logs."
|
||||
: "Download a CSV export of your workout logs."}
|
||||
</p>
|
||||
|
||||
{exportError && (
|
||||
@@ -257,30 +261,32 @@ function DatabaseExport() {
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
triggerDownload(
|
||||
"/api/settings/export-db",
|
||||
"proof-of-work-backup.db",
|
||||
setExportingDb
|
||||
)
|
||||
}
|
||||
disabled={exportingDb || exportingCsv}
|
||||
className="py-3 border border-zinc-700 rounded-lg text-zinc-300 text-sm font-medium hover:text-white hover:border-zinc-500 disabled:opacity-50 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
{exportingDb ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Exporting DB...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Export SQLite (.db)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
triggerDownload(
|
||||
"/api/settings/export-db",
|
||||
"proof-of-work-backup.db",
|
||||
setExportingDb
|
||||
)
|
||||
}
|
||||
disabled={exportingDb || exportingCsv}
|
||||
className="py-3 border border-zinc-700 rounded-lg text-zinc-300 text-sm font-medium hover:text-white hover:border-zinc-500 disabled:opacity-50 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
{exportingDb ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Exporting DB...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Export SQLite (.db)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -16,6 +16,16 @@ export function getProvider(id: string): LLMProvider | null {
|
||||
return (ALL as Record<string, LLMProvider | undefined>)[id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for providers that take a user-supplied base URL (Ollama,
|
||||
* OpenAI-compatible). Configuring these is admin-only — a non-admin pointing
|
||||
* the server at an arbitrary URL is the SSRF actor vector (EVALUATION.md P1).
|
||||
* The fixed-URL cloud providers (claude/openai/gemini) stay per-user.
|
||||
*/
|
||||
export function isCustomUrlProvider(id: string): boolean {
|
||||
return !!getProvider(id)?.requiresBaseUrl;
|
||||
}
|
||||
|
||||
/** Stable list for UI dropdowns. Order matches the Settings select. */
|
||||
export const PROVIDER_ORDER: ProviderId[] = [
|
||||
'claude',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
|
||||
import { ndjsonLines } from '../sse';
|
||||
import { assertSafeProviderUrl } from '../safeUrl';
|
||||
|
||||
/**
|
||||
* Ollama: streaming NDJSON over POST /api/chat.
|
||||
@@ -20,6 +21,12 @@ export const ollama: LLMProvider = {
|
||||
return;
|
||||
}
|
||||
const url = opts.baseUrl.replace(/\/$/, '') + '/api/chat';
|
||||
try {
|
||||
await assertSafeProviderUrl(url);
|
||||
} catch (e) {
|
||||
yield { type: 'error', message: (e as Error).message };
|
||||
return;
|
||||
}
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
|
||||
import { sseLines } from '../sse';
|
||||
import { assertSafeProviderUrl } from '../safeUrl';
|
||||
|
||||
/**
|
||||
* Generic chat-completions streamer used by both OpenAI and the
|
||||
@@ -110,6 +111,14 @@ export const openaiCompatible: LLMProvider = {
|
||||
};
|
||||
return;
|
||||
}
|
||||
// User-supplied base URL → SSRF guard (the fixed-URL `openai` provider
|
||||
// above skips this since api.openai.com is not attacker-controlled).
|
||||
try {
|
||||
await assertSafeProviderUrl(opts.baseUrl);
|
||||
} catch (e) {
|
||||
yield { type: 'error', message: `OpenAI-compatible: ${(e as Error).message}` };
|
||||
return;
|
||||
}
|
||||
yield* generateOpenAIStyle(opts, opts.baseUrl, 'OpenAI-compatible');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
|
||||
/**
|
||||
* SSRF guard for user-supplied AI provider base URLs.
|
||||
*
|
||||
* On a self-hosted box, pointing a provider at a private-LAN service — Ollama
|
||||
* at ollama.startos:11434, a LiteLLM/vLLM gateway on 192.168.x, localhost in
|
||||
* dev — is a *feature*, so we deliberately ALLOW private and loopback ranges.
|
||||
* We block only targets that are never a legitimate provider and are valuable
|
||||
* to an attacker: cloud-metadata / link-local (169.254.0.0/16, fe80::/10) and
|
||||
* unspecified addresses (0.0.0.0, ::), plus any non-http(s) scheme.
|
||||
*
|
||||
* We resolve the hostname and check every address it maps to, so a public
|
||||
* name that resolves to a blocked range is caught too. (Note: this is a
|
||||
* pre-flight check; it does not pin the resolved IP, so a DNS-rebinding race
|
||||
* is out of scope — acceptable here since private ranges are allowed anyway.)
|
||||
*/
|
||||
export async function assertSafeProviderUrl(rawUrl: string): Promise<void> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error("Invalid URL.");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("Only http and https URLs are allowed.");
|
||||
}
|
||||
|
||||
// URL.hostname keeps the brackets on IPv6 literals ([::1]); strip BOTH
|
||||
// (the /g matters — without it only the leading bracket goes, leaving a
|
||||
// trailing ] that fails net.isIP and falls through to a bogus DNS lookup).
|
||||
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
||||
|
||||
let addresses: string[];
|
||||
if (net.isIP(hostname)) {
|
||||
addresses = [hostname];
|
||||
} else {
|
||||
try {
|
||||
const resolved = await lookup(hostname, { all: true });
|
||||
addresses = resolved.map((r) => r.address);
|
||||
} catch {
|
||||
throw new Error(`Could not resolve host: ${hostname}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const address of addresses) {
|
||||
if (isBlockedAddress(address)) {
|
||||
throw new Error(
|
||||
`Refusing to connect to ${hostname} (${address}): ` +
|
||||
"link-local / cloud-metadata addresses are not allowed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True for addresses that are never a legitimate provider target: the
|
||||
* unspecified address and the link-local / cloud-metadata range. Private
|
||||
* (RFC1918) and loopback addresses return false on purpose — see the module
|
||||
* comment. Exported for unit testing.
|
||||
*/
|
||||
export function isBlockedAddress(ip: string): boolean {
|
||||
const family = net.isIP(ip);
|
||||
if (family === 4) {
|
||||
const o = ip.split(".").map(Number);
|
||||
if (o[0] === 0) return true; // 0.0.0.0/8 ("this network" — never a valid target)
|
||||
if (o[0] === 169 && o[1] === 254) return true; // 169.254.0.0/16 link-local + metadata
|
||||
return false;
|
||||
}
|
||||
if (family === 6) {
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === "::") return true; // unspecified
|
||||
// fe80::/10 link-local spans fe80..febf
|
||||
if (/^fe[89ab]/.test(lower)) return true;
|
||||
// IPv4-mapped (::ffff:a.b.c.d) — re-check the embedded v4 address
|
||||
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (mapped) return isBlockedAddress(mapped[1]);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import path from "path";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
export function resolveDatabasePath(): string {
|
||||
const dbUrl = process.env.DATABASE_URL || "file:./data/app.db";
|
||||
@@ -14,14 +13,13 @@ export function resolveDatabasePath(): string {
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
// Prisma resolves a relative `file:` URL against the schema directory
|
||||
// (prisma/), NOT the cwd. We must resolve it the same way, otherwise we
|
||||
// can hand back a stray empty ./data/app.db (created by a cwd-relative
|
||||
// `prisma db push`) while the live DB Prisma actually uses sits under
|
||||
// prisma/data/ — which made export-db stream a 0-byte file in dev.
|
||||
const normalized = rawPath.replace(/^\.\//, "");
|
||||
const directPath = path.resolve(process.cwd(), normalized);
|
||||
if (existsSync(directPath)) {
|
||||
return directPath;
|
||||
}
|
||||
|
||||
const prismaPath = path.resolve(process.cwd(), "prisma", normalized);
|
||||
return prismaPath;
|
||||
return path.resolve(process.cwd(), "prisma", normalized);
|
||||
}
|
||||
|
||||
export function getTimestampFileSuffix(now: Date = new Date()): string {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"lint": "next lint",
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "npx tsx prisma/seed.ts",
|
||||
"create-admin": "npx tsx scripts/create-admin.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"sync-library": "node scripts/sync-library.cjs",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* create-admin — local-dev helper to create the first admin user.
|
||||
*
|
||||
* Fresh installs ship with ZERO users by design (see prisma/seed.ts): in
|
||||
* production the operator creates the first admin via the StartOS Action
|
||||
* "Set admin credentials". Local dev has no StartOS, so this script is the
|
||||
* equivalent — it creates an admin and seeds their curated library, exactly
|
||||
* like the StartOS action does.
|
||||
*
|
||||
* Usage (from proof-of-work/):
|
||||
* npm run create-admin -- <email> <password> [name]
|
||||
* ADMIN_EMAIL=me@example.com ADMIN_PASSWORD=secret123 npm run create-admin
|
||||
*
|
||||
* The email must be a real-looking address (login/signup validate the TLD);
|
||||
* "admin@local" will NOT work.
|
||||
*/
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { hashPassword } from "../lib/auth";
|
||||
import { ensureLibraryForUser } from "../lib/library";
|
||||
|
||||
async function main() {
|
||||
const force = process.argv.includes("--force");
|
||||
const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
||||
const email = positional[0] || process.env.ADMIN_EMAIL;
|
||||
const password = positional[1] || process.env.ADMIN_PASSWORD;
|
||||
const name = positional[2] || process.env.ADMIN_NAME || null;
|
||||
|
||||
if (!email || !password) {
|
||||
console.error(
|
||||
"usage: npm run create-admin -- <email> <password> [name]\n" +
|
||||
" or: ADMIN_EMAIL=... ADMIN_PASSWORD=... npm run create-admin",
|
||||
);
|
||||
process.exit(64);
|
||||
}
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
console.error(
|
||||
`Refusing "${email}": login/signup require a real email with a TLD ` +
|
||||
'(e.g. you@example.com). "admin@local" will not work.',
|
||||
);
|
||||
process.exit(64);
|
||||
}
|
||||
if (password.length < 8) {
|
||||
console.error("Password must be at least 8 characters.");
|
||||
process.exit(64);
|
||||
}
|
||||
|
||||
// Guard the footgun: a typo'd or reused email would otherwise silently
|
||||
// reset an existing user's password AND promote them to admin. Require
|
||||
// --force to touch an account that already exists.
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing && !force) {
|
||||
console.error(
|
||||
`A user with ${email} already exists (isAdmin=${existing.isAdmin}). ` +
|
||||
"Re-run with --force to RESET its password and promote it to admin.",
|
||||
);
|
||||
process.exit(73);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Upsert so --force re-running promotes/repairs an existing account rather
|
||||
// than erroring on the unique-email constraint.
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { passwordHash, isAdmin: true },
|
||||
create: {
|
||||
email,
|
||||
passwordHash,
|
||||
name: name?.trim() || null,
|
||||
isAdmin: true,
|
||||
userPreferences: {
|
||||
create: {
|
||||
theme: "system",
|
||||
defaultWeightUnit: "lbs",
|
||||
defaultRestSeconds: 90,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const added = await ensureLibraryForUser(user.id);
|
||||
console.log(
|
||||
`Admin ready: ${email} (id ${user.id}); seeded ${added} library exercises.`,
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("create-admin failed:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { assertSafeProviderUrl, isBlockedAddress } from '@/lib/ai/safeUrl';
|
||||
|
||||
// SSRF guard for user-supplied provider base URLs (EVALUATION.md P1).
|
||||
// Self-hosted intent: private-LAN + loopback are ALLOWED (Ollama, gateways,
|
||||
// dev); only link-local/cloud-metadata + unspecified + non-http(s) are blocked.
|
||||
|
||||
describe('isBlockedAddress', () => {
|
||||
it('blocks link-local / cloud-metadata and unspecified', () => {
|
||||
expect(isBlockedAddress('169.254.169.254')).toBe(true); // cloud metadata
|
||||
expect(isBlockedAddress('169.254.0.1')).toBe(true);
|
||||
expect(isBlockedAddress('0.0.0.0')).toBe(true);
|
||||
expect(isBlockedAddress('::')).toBe(true);
|
||||
expect(isBlockedAddress('fe80::1')).toBe(true); // IPv6 link-local
|
||||
expect(isBlockedAddress('::ffff:169.254.169.254')).toBe(true); // mapped
|
||||
});
|
||||
|
||||
it('allows private LAN, loopback, and public (the legitimate targets)', () => {
|
||||
expect(isBlockedAddress('192.168.1.10')).toBe(false);
|
||||
expect(isBlockedAddress('10.0.0.5')).toBe(false);
|
||||
expect(isBlockedAddress('172.16.0.9')).toBe(false);
|
||||
expect(isBlockedAddress('127.0.0.1')).toBe(false);
|
||||
expect(isBlockedAddress('1.1.1.1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertSafeProviderUrl', () => {
|
||||
it('rejects non-http(s) schemes', async () => {
|
||||
await expect(assertSafeProviderUrl('ftp://example.com')).rejects.toThrow();
|
||||
await expect(assertSafeProviderUrl('file:///etc/passwd')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unparseable URL', async () => {
|
||||
await expect(assertSafeProviderUrl('not a url')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects cloud-metadata / link-local IP literals', async () => {
|
||||
await expect(
|
||||
assertSafeProviderUrl('http://169.254.169.254/latest/meta-data/'),
|
||||
).rejects.toThrow();
|
||||
await expect(assertSafeProviderUrl('http://0.0.0.0:11434')).rejects.toThrow();
|
||||
await expect(assertSafeProviderUrl('http://[fe80::1]:11434')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('allows private-LAN and loopback Ollama targets', async () => {
|
||||
await expect(
|
||||
assertSafeProviderUrl('http://192.168.1.50:11434/api/chat'),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
assertSafeProviderUrl('http://127.0.0.1:11434/api/tags'),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
assertSafeProviderUrl('http://10.0.0.2:8000/v1/chat/completions'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// IPv6 literals carry brackets in URL.hostname; the guard must strip BOTH
|
||||
// and classify by the real address (catches a missing-/g bracket-strip bug).
|
||||
it('handles bracketed IPv6 literals correctly', async () => {
|
||||
await expect(
|
||||
assertSafeProviderUrl('http://[::1]:11434/api/tags'),
|
||||
).resolves.toBeUndefined(); // loopback allowed
|
||||
await expect(
|
||||
assertSafeProviderUrl('http://[fe80::1]:11434'),
|
||||
).rejects.toThrow(/link-local/); // blocked by isBlockedAddress, not a DNS miss
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { resolveDatabasePath } from '@/lib/db-file';
|
||||
|
||||
// resolveDatabasePath MUST agree with how Prisma resolves DATABASE_URL, or
|
||||
// export-db/import-db operate on a different file than the live DB — the dev
|
||||
// "0-byte export" bug (EVALUATION.md P1). Prisma resolves a relative `file:`
|
||||
// URL against the schema dir (prisma/), not the cwd.
|
||||
|
||||
const original = process.env.DATABASE_URL;
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.DATABASE_URL;
|
||||
else process.env.DATABASE_URL = original;
|
||||
});
|
||||
|
||||
describe('resolveDatabasePath', () => {
|
||||
it('resolves a relative file: URL against prisma/, matching Prisma', () => {
|
||||
process.env.DATABASE_URL = 'file:./data/app.db';
|
||||
expect(resolveDatabasePath()).toBe(
|
||||
path.resolve(process.cwd(), 'prisma', 'data', 'app.db'),
|
||||
);
|
||||
});
|
||||
|
||||
it('strips a leading ./ but keeps the prisma/ anchor', () => {
|
||||
process.env.DATABASE_URL = 'file:data/app.db';
|
||||
expect(resolveDatabasePath()).toBe(
|
||||
path.resolve(process.cwd(), 'prisma', 'data', 'app.db'),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an absolute file: path unchanged (production /data)', () => {
|
||||
process.env.DATABASE_URL = 'file:/data/app.db';
|
||||
expect(resolveDatabasePath()).toBe('/data/app.db');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const { getCurrentUserMock } = vi.hoisted(() => ({
|
||||
getCurrentUserMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/auth', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, getCurrentUser: getCurrentUserMock };
|
||||
});
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { POST as createConfig } from '@/app/api/ai/configs/route';
|
||||
import { POST as testConfig } from '@/app/api/ai/test/route';
|
||||
import { GET as ollamaModels } from '@/app/api/ai/ollama/models/route';
|
||||
|
||||
// Custom-URL providers (Ollama / OpenAI-compatible) are admin-only — a
|
||||
// non-admin pointing the server at an arbitrary URL is the SSRF actor vector
|
||||
// (EVALUATION.md P1). Fixed-URL cloud providers (claude/openai/gemini) stay
|
||||
// per-user. These tests lock that boundary.
|
||||
|
||||
function jsonReq(url: string, body: unknown): NextRequest {
|
||||
return new NextRequest(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
async function makeUser(email: string, isAdmin: boolean) {
|
||||
return prisma.user.create({
|
||||
data: { email, passwordHash: 'fake', isAdmin },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.aIConfigProfile.deleteMany();
|
||||
await prisma.userPreferences.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
getCurrentUserMock.mockReset();
|
||||
});
|
||||
|
||||
describe('POST /api/ai/configs — custom-URL providers are admin-only', () => {
|
||||
it('rejects a non-admin creating an ollama (custom-URL) config', async () => {
|
||||
const u = await makeUser('u@x.com', false);
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await createConfig(
|
||||
jsonReq('http://x/api/ai/configs', {
|
||||
provider: 'ollama',
|
||||
model: 'llama3',
|
||||
baseUrl: 'http://ollama.startos:11434',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a non-admin supplying a baseUrl with any provider', async () => {
|
||||
const u = await makeUser('u2@x.com', false);
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await createConfig(
|
||||
jsonReq('http://x/api/ai/configs', {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
baseUrl: 'http://169.254.169.254',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows a non-admin creating a fixed-URL cloud config (claude)', async () => {
|
||||
const u = await makeUser('u3@x.com', false);
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await createConfig(
|
||||
jsonReq('http://x/api/ai/configs', {
|
||||
provider: 'claude',
|
||||
model: 'claude-sonnet-4-6',
|
||||
apiKey: 'sk-test',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('allows an admin creating an ollama config', async () => {
|
||||
const a = await makeUser('admin@x.com', true);
|
||||
getCurrentUserMock.mockResolvedValue(a);
|
||||
const res = await createConfig(
|
||||
jsonReq('http://x/api/ai/configs', {
|
||||
provider: 'ollama',
|
||||
model: 'llama3',
|
||||
baseUrl: 'http://ollama.startos:11434',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/ai/test — testing a custom base URL is admin-only', () => {
|
||||
it('rejects a non-admin testing an openai-compatible draft', async () => {
|
||||
getCurrentUserMock.mockResolvedValue({ id: 'u', email: 'u@x.com', isAdmin: false });
|
||||
const res = await testConfig(
|
||||
jsonReq('http://x/api/ai/test', {
|
||||
provider: 'openai-compatible',
|
||||
model: 'x',
|
||||
baseUrl: 'http://192.168.0.1',
|
||||
apiKey: 'k',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/ai/ollama/models — admin-only', () => {
|
||||
it('returns 403 for a non-admin', async () => {
|
||||
getCurrentUserMock.mockResolvedValue({ id: 'u', email: 'u@x.com', isAdmin: false });
|
||||
const res = await ollamaModels(
|
||||
new NextRequest('http://x/api/ai/ollama/models?baseUrl=http://192.168.0.1'),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(null);
|
||||
const res = await ollamaModels(new NextRequest('http://x/api/ai/ollama/models'));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
PATCH as patchTemplate,
|
||||
DELETE as deleteTemplate,
|
||||
} from '@/app/api/ai/templates/[id]/route';
|
||||
import { GET as getConfig, POST as setConfig } from '@/app/api/ai/config/route';
|
||||
|
||||
function jsonReq(url: string, body?: unknown, method?: string): NextRequest {
|
||||
return new NextRequest(url, {
|
||||
@@ -207,58 +206,3 @@ describe('DELETE /api/ai/templates/[id]', () => {
|
||||
expect(await prisma.aIPromptTemplate.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/ai/config', () => {
|
||||
it('GET returns aiKeyConfigured flag, never the plaintext key', async () => {
|
||||
const me = await prisma.user.create({
|
||||
data: { email: 'me@x', passwordHash: 'fake' },
|
||||
});
|
||||
await prisma.userPreferences.create({
|
||||
data: {
|
||||
userId: me.id,
|
||||
aiProvider: 'claude',
|
||||
aiModel: 'claude-sonnet-4-5',
|
||||
aiApiKey: 'sk-ant-secret',
|
||||
},
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(me);
|
||||
const body = await (await getConfig()).json();
|
||||
expect(body.aiProvider).toBe('claude');
|
||||
expect(body.aiModel).toBe('claude-sonnet-4-5');
|
||||
expect(body.aiKeyConfigured).toBe(true);
|
||||
expect(JSON.stringify(body)).not.toContain('sk-ant-secret');
|
||||
});
|
||||
|
||||
it('POST persists provider config', async () => {
|
||||
const me = await prisma.user.create({
|
||||
data: { email: 'me@x', passwordHash: 'fake' },
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(me);
|
||||
const res = await setConfig(
|
||||
jsonReq('http://x/api/ai/config', {
|
||||
aiProvider: 'ollama',
|
||||
aiModel: 'llama3.1:8b',
|
||||
aiBaseUrl: 'http://ollama.embassy:11434',
|
||||
aiApiKey: null,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const prefs = await prisma.userPreferences.findUnique({
|
||||
where: { userId: me.id },
|
||||
});
|
||||
expect(prefs?.aiProvider).toBe('ollama');
|
||||
expect(prefs?.aiBaseUrl).toBe('http://ollama.embassy:11434');
|
||||
expect(prefs?.aiApiKey).toBeNull();
|
||||
});
|
||||
|
||||
it('POST validates provider enum', async () => {
|
||||
const me = await prisma.user.create({
|
||||
data: { email: 'me@x', passwordHash: 'fake' },
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(me);
|
||||
const res = await setConfig(
|
||||
jsonReq('http://x/api/ai/config', { aiProvider: 'made-up-provider' }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const { getCurrentUserMock } = vi.hoisted(() => ({
|
||||
getCurrentUserMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/auth', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, getCurrentUser: getCurrentUserMock };
|
||||
});
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { GET as exportDb } from '@/app/api/settings/export-db/route';
|
||||
import { POST as importDb } from '@/app/api/settings/import-db/route';
|
||||
|
||||
// The whole-instance DB export/import operate on every user's data (hashes,
|
||||
// plaintext AI keys) and can replace the entire DB. They MUST be admin-only —
|
||||
// see EVALUATION.md P0. These tests lock that gate so it can't silently regress.
|
||||
//
|
||||
// The admin "happy path" cases assert a real downstream status (export 200 /
|
||||
// import 400), not merely "not 401/403" — so they can't pass vacuously if the
|
||||
// route errors before reaching the gate. The export reads the live test DB,
|
||||
// which setup-actions.ts has already created at DATABASE_URL.
|
||||
|
||||
const regularUser = { id: 'u1', email: 'user@example.com', isAdmin: false };
|
||||
const adminUser = { id: 'a1', email: 'admin@example.com', isAdmin: true };
|
||||
|
||||
// POST with no body — formData() throws downstream; only the gate matters here.
|
||||
function emptyImportReq(): NextRequest {
|
||||
return new NextRequest('http://x/api/settings/import-db', {
|
||||
method: 'POST',
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
// POST with a structurally-present but non-SQLite file: passes the gate, then
|
||||
// fails the magic-byte check with a clean 400 — proving the gate was cleared.
|
||||
function badFileImportReq(): NextRequest {
|
||||
const form = new FormData();
|
||||
form.append('database', new File([Buffer.from('not a sqlite db')], 'x.db'));
|
||||
return new NextRequest('http://x/api/settings/import-db', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getCurrentUserMock.mockReset();
|
||||
});
|
||||
|
||||
describe('GET /api/settings/export-db (whole-instance DB)', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(null);
|
||||
expect((await exportDb()).status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for a non-admin user', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(regularUser);
|
||||
expect((await exportDb()).status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns the DB file (200) for an admin', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(adminUser);
|
||||
const res = await exportDb();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('application/x-sqlite3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/settings/import-db (whole-instance DB replace)', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(null);
|
||||
expect((await importDb(emptyImportReq())).status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for a non-admin user', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(regularUser);
|
||||
expect((await importDb(emptyImportReq())).status).toBe(403);
|
||||
});
|
||||
|
||||
it('lets an admin past the gate (400 at the magic-byte check, not 401/403)', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(adminUser);
|
||||
// A non-SQLite file clears the admin gate and is rejected at the magic-byte
|
||||
// check with 400 — unambiguously "past the gate", not a vacuous pass.
|
||||
expect((await importDb(badFileImportReq())).status).toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user