v1.1.0:8 — admin-gate whole-DB routes + AI custom-URL providers; SSRF guard
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled

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:
Keysat
2026-06-12 23:15:09 -05:00
parent 09eeef249d
commit 988a3cca9a
30 changed files with 815 additions and 195 deletions
+2 -2
View File
@@ -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
View File
@@ -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
-80
View File
@@ -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;
+15
View File
@@ -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);
+14 -1
View File
@@ -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;
+1 -1
View File
@@ -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"
+10
View File
@@ -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',
+7
View File
@@ -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, {
+9
View File
@@ -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');
},
};
+82
View File
@@ -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;
}
+6 -8
View File
@@ -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 {
+1
View File
@@ -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",
+92
View File
@@ -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());
+67
View File
@@ -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
});
});
+35
View File
@@ -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');
});
});
+126
View File
@@ -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);
});
});