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:
+3
-2
@@ -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",
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user