feat(api): add first-run setup endpoint for admin bootstrap

POST /api/v1/auth/setup — unauthenticated, only succeeds when the
users table is empty. Enables first-admin creation via HTTP for
Docker deployments without shell access to the container volume.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-04-21 13:10:41 +02:00
parent 143582458e
commit e4b97368fb
3 changed files with 142 additions and 2 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "oikos",
"version": "0.20.42",
"version": "0.20.43",
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js",
"type": "module",
@@ -22,9 +22,10 @@
"test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js",
"test:reminders": "node --experimental-sqlite test-reminders.js",
"test:api": "node test-api.js",
"test:setup": "node test-setup.js",
"test:ics-parser": "node test-ics-parser.js",
"test:ics-sub": "node --experimental-sqlite test-ics-subscription.js",
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub"
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup"
},
"dependencies": {
"bcrypt": "^6.0.0",
+52
View File
@@ -153,6 +153,8 @@ function requireAdmin(req, res, next) {
// Routen
// --------------------------------------------------------
const avatarColors = ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#FF2D55'];
/**
* POST /api/v1/auth/login
* Body: { username: string, password: string }
@@ -233,6 +235,56 @@ router.post('/logout', requireAuth, csrfMiddleware, (req, res) => {
});
});
/**
* POST /api/v1/auth/setup
* First-run bootstrap: creates the first admin when no users exist.
* Returns 403 if any user already exists.
* Body: { username: string, display_name: string, password: string }
* Response: { user: { id, username, display_name, avatar_color, role } }
*/
router.post('/setup', loginLimiter, async (req, res) => {
try {
const { count } = db.get().prepare('SELECT COUNT(*) as count FROM users').get();
if (count > 0) {
return res.status(403).json({ error: 'Setup bereits abgeschlossen.', code: 403 });
}
const username = (req.body.username || '').trim();
const display_name = (req.body.display_name || '').trim();
const { password } = req.body;
if (!username || !display_name || !password) {
return res.status(400).json({ error: 'Benutzername, Anzeigename und Passwort erforderlich.', code: 400 });
}
if (!/^[a-zA-Z0-9._-]{3,64}$/.test(username)) {
return res.status(400).json({ error: 'Benutzername muss 3-64 Zeichen lang sein und darf nur Buchstaben, Zahlen, Punkte, Bindestriche und Unterstriche enthalten.', code: 400 });
}
if (display_name.length > 128) {
return res.status(400).json({ error: 'Anzeigename darf maximal 128 Zeichen lang sein.', code: 400 });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen haben.', code: 400 });
}
const avatarColor = avatarColors[Math.floor(Math.random() * avatarColors.length)];
const hash = await bcrypt.hash(password, 12);
const result = db.get()
.prepare('INSERT INTO users (username, display_name, password_hash, avatar_color, role) VALUES (?, ?, ?, ?, ?)')
.run(username, display_name, hash, avatarColor, 'admin');
res.status(201).json({
user: { id: result.lastInsertRowid, username, display_name, avatar_color: avatarColor, role: 'admin' },
});
} catch (err) {
if (err.message?.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'Benutzername bereits vergeben.', code: 409 });
}
log.error('Setup error:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
/**
* GET /api/v1/auth/me
* Response: { user: { id, username, display_name, avatar_color, role } }
+87
View File
@@ -0,0 +1,87 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const tmpDir = mkdtempSync(join(tmpdir(), 'oikos-setup-test-'));
process.env.SESSION_SECRET = 'test-setup-secret-minimum-32-chars-x';
process.env.DB_PATH = join(tmpDir, 'test.db');
process.env.SESSION_SECURE = 'false';
process.env.PORT = '13099';
// Dynamic import so env vars are set before module initialization
const { default: app } = await import('./server/index.js');
await new Promise(r => setTimeout(r, 400));
const BASE = 'http://localhost:13099';
after(() => {
rmSync(tmpDir, { recursive: true, force: true });
process.exit(0);
});
// Validation tests run first (DB is empty at this point)
test('POST /api/v1/auth/setup: 400 when required fields missing', async () => {
const res = await fetch(`${BASE}/api/v1/auth/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin' }),
});
assert.equal(res.status, 400);
});
test('POST /api/v1/auth/setup: 400 when username invalid', async () => {
const res = await fetch(`${BASE}/api/v1/auth/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'a!b', display_name: 'Test', password: 'password123' }),
});
assert.equal(res.status, 400);
});
test('POST /api/v1/auth/setup: 400 when password too short', async () => {
const res = await fetch(`${BASE}/api/v1/auth/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', display_name: 'Test', password: 'short' }),
});
assert.equal(res.status, 400);
});
test('POST /api/v1/auth/setup: 400 when display_name too long', async () => {
const res = await fetch(`${BASE}/api/v1/auth/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', display_name: 'x'.repeat(129), password: 'password123' }),
});
assert.equal(res.status, 400);
});
test('POST /api/v1/auth/setup: 201 creates first admin', async () => {
const res = await fetch(`${BASE}/api/v1/auth/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', display_name: 'Test Admin', password: 'password123' }),
});
assert.equal(res.status, 201);
const data = await res.json();
assert.equal(data.user.username, 'admin');
assert.equal(data.user.display_name, 'Test Admin');
assert.equal(data.user.role, 'admin');
assert.ok(typeof data.user.id === 'number');
assert.ok(typeof data.user.avatar_color === 'string' && data.user.avatar_color.startsWith('#'));
});
test('POST /api/v1/auth/setup: 403 when users already exist', async () => {
const res = await fetch(`${BASE}/api/v1/auth/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin2', display_name: 'Another', password: 'password123' }),
});
assert.equal(res.status, 403);
const data = await res.json();
assert.equal(data.code, 403);
});