Build MVP with Pi installer and atomic updates

This commit is contained in:
2026-07-31 18:17:27 +02:00
parent e575ed2b2f
commit c52def7c37
35 changed files with 7762 additions and 1 deletions
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@pi-car/server",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"lint": "eslint src tests",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/rate-limit": "^10.3.0",
"argon2": "^0.44.0",
"better-sqlite3": "^12.2.0",
"fastify": "^5.4.0",
"zod": "^4.0.14"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.17.0",
"tsx": "^4.20.3"
}
}
+365
View File
@@ -0,0 +1,365 @@
import { createReadStream, existsSync, statSync } from "node:fs";
import { extname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import argon2 from "argon2";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
import { z } from "zod";
import type { AppConfig } from "./config.js";
import { openDatabase, type CompanionDatabase } from "./database.js";
import {
clientAddress,
CSRF_COOKIE,
hashToken,
randomToken,
safeEqual,
SESSION_COOKIE
} from "./security.js";
import { collectSystemStatus } from "./status.js";
import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
import "./types.js";
const credentialsSchema = z.object({
username: z
.string()
.trim()
.min(3, "Username must contain at least 3 characters")
.max(48, "Username must contain at most 48 characters")
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, "Use letters, numbers, dots, hyphens, or underscores"),
password: z.string().min(12, "Password must contain at least 12 characters").max(256)
});
type UserRow = { id: number; username: string; role: "admin"; password_hash: string };
type SessionUserRow = { id: number; username: string; role: "admin" };
function audit(
database: CompanionDatabase,
event: {
actorUserId?: number;
action: string;
result: "success" | "failure";
ipAddress?: string;
details?: Record<string, string | number | boolean>;
}
): void {
database
.prepare(
`INSERT INTO audit_events
(actor_user_id, action, result, ip_address, details_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(
event.actorUserId ?? null,
event.action,
event.result,
event.ipAddress ?? null,
JSON.stringify(event.details ?? {}),
new Date().toISOString()
);
}
function cookieOptions(config: AppConfig, httpOnly: boolean) {
return {
path: "/",
httpOnly,
sameSite: "strict" as const,
secure: config.cookieSecure
};
}
function requireUser(request: FastifyRequest, reply: FastifyReply): boolean {
if (request.authUser) return true;
void reply.code(401).send({ error: { code: "AUTH_REQUIRED", message: "Sign in to continue" } });
return false;
}
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
const app = Fastify({
logger:
config.nodeEnv === "test"
? false
: {
level: config.nodeEnv === "production" ? "info" : "debug",
redact: ["req.headers.cookie", "req.headers.authorization", "req.headers.x-csrf-token"]
}
});
const database = openDatabase(config.databasePath);
app.decorate("database", database);
app.decorateRequest("authUser", null);
await app.register(cookie);
await app.register(rateLimit, { global: false });
app.addHook("onClose", async () => database.close());
app.addHook("onSend", async (_request, reply) => {
reply.header("X-Content-Type-Options", "nosniff");
reply.header("Referrer-Policy", "no-referrer");
reply.header("X-Frame-Options", "DENY");
reply.header(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
);
});
app.addHook("onRequest", async (request) => {
request.authUser = null;
const token = request.cookies[SESSION_COOKIE];
if (!token) return;
const now = new Date().toISOString();
const user = database
.prepare(
`SELECT users.id, users.username, users.role
FROM sessions JOIN users ON users.id = sessions.user_id
WHERE sessions.id_hash = ? AND sessions.expires_at > ?`
)
.get(hashToken(token), now) as SessionUserRow | undefined;
if (user) request.authUser = user;
});
app.addHook("preValidation", async (request, reply) => {
if (!["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) return;
const origin = request.headers.origin;
let sameHostOrigin = false;
if (origin && request.headers.host) {
try {
const parsedOrigin = new URL(origin);
sameHostOrigin = ["http:", "https:"].includes(parsedOrigin.protocol) && parsedOrigin.host === request.headers.host;
} catch {
sameHostOrigin = false;
}
}
if (origin && !sameHostOrigin && !config.allowedOrigins.has(origin)) {
return reply.code(403).send({ error: { code: "ORIGIN_REJECTED", message: "Request origin is not trusted" } });
}
const cookieToken = request.cookies[CSRF_COOKIE];
const headerToken = request.headers["x-csrf-token"];
if (!cookieToken || typeof headerToken !== "string" || !safeEqual(cookieToken, headerToken)) {
return reply.code(403).send({ error: { code: "CSRF_REJECTED", message: "Refresh the page and try again" } });
}
});
app.get("/healthz", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
app.get("/api/auth/state", async (request, reply) => {
let csrfToken = request.cookies[CSRF_COOKIE];
if (!csrfToken) {
csrfToken = randomToken();
reply.setCookie(CSRF_COOKIE, csrfToken, cookieOptions(config, false));
}
const row = database.prepare("SELECT COUNT(*) AS count FROM users").get() as { count: number };
return {
setupRequired: row.count === 0,
user: request.authUser,
csrfToken
};
});
app.post(
"/api/auth/setup",
{ config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (request, reply) => {
const parsed = credentialsSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
error: { code: "INVALID_CREDENTIALS", message: parsed.error.issues[0]?.message ?? "Invalid account details" }
});
}
const passwordHash = await argon2.hash(parsed.data.password, { type: argon2.argon2id });
const createAdmin = database.transaction(() => {
const row = database.prepare("SELECT COUNT(*) AS count FROM users").get() as { count: number };
if (row.count !== 0) return null;
const result = database
.prepare("INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, 'admin', ?)")
.run(parsed.data.username, passwordHash, new Date().toISOString());
return Number(result.lastInsertRowid);
});
const userId = createAdmin();
if (userId === null) {
audit(database, {
action: "auth.setup",
result: "failure",
ipAddress: clientAddress(request),
details: { reason: "setup_already_complete" }
});
return reply.code(409).send({ error: { code: "SETUP_COMPLETE", message: "Initial setup is already complete" } });
}
audit(database, {
actorUserId: userId,
action: "auth.setup",
result: "success",
ipAddress: clientAddress(request)
});
return reply.code(201).send({ created: true });
}
);
app.post(
"/api/auth/login",
{ config: { rateLimit: { max: 8, timeWindow: "1 minute" } } },
async (request, reply) => {
const parsed = credentialsSchema.safeParse(request.body);
const user = parsed.success
? (database
.prepare("SELECT id, username, role, password_hash FROM users WHERE username = ? COLLATE NOCASE")
.get(parsed.data.username) as UserRow | undefined)
: undefined;
const valid = user && parsed.success ? await argon2.verify(user.password_hash, parsed.data.password) : false;
if (!user || !valid) {
audit(database, {
action: "auth.login",
result: "failure",
ipAddress: clientAddress(request),
details: { reason: "invalid_credentials" }
});
return reply.code(401).send({ error: { code: "LOGIN_FAILED", message: "Username or password is incorrect" } });
}
database.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(new Date().toISOString());
const token = randomToken();
const expiresAt = new Date(Date.now() + config.sessionTtlMs);
database
.prepare("INSERT INTO sessions (id_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
.run(hashToken(token), user.id, expiresAt.toISOString(), new Date().toISOString());
reply.setCookie(SESSION_COOKIE, token, {
...cookieOptions(config, true),
expires: expiresAt
});
audit(database, {
actorUserId: user.id,
action: "auth.login",
result: "success",
ipAddress: clientAddress(request)
});
return { user: { id: user.id, username: user.username, role: user.role } };
}
);
app.post("/api/auth/logout", async (request, reply) => {
const token = request.cookies[SESSION_COOKIE];
if (token) database.prepare("DELETE FROM sessions WHERE id_hash = ?").run(hashToken(token));
if (request.authUser) {
audit(database, {
actorUserId: request.authUser.id,
action: "auth.logout",
result: "success",
ipAddress: clientAddress(request)
});
}
reply.clearCookie(SESSION_COOKIE, cookieOptions(config, true));
return { loggedOut: true };
});
app.get("/api/status", async (request, reply) => {
if (!requireUser(request, reply)) return;
return collectSystemStatus();
});
app.get("/api/system/update", async (request, reply) => {
if (!requireUser(request, reply)) return;
return readUpdateStatus(config);
});
app.post(
"/api/system/update",
{ config: { rateLimit: { max: 2, timeWindow: "10 minutes" } } },
async (request, reply) => {
if (!requireUser(request, reply)) return;
const current = await readUpdateStatus(config);
if (!current.supported) {
return reply.code(503).send({
error: { code: "UPDATES_UNAVAILABLE", message: "Install the system service before using automatic updates" }
});
}
if (["checking", "building"].includes(current.state)) {
return reply.code(409).send({ error: { code: "UPDATE_IN_PROGRESS", message: "An update is already running" } });
}
try {
await triggerSystemUpdate();
audit(database, {
actorUserId: request.authUser!.id,
action: "system.update",
result: "success",
ipAddress: clientAddress(request),
details: { state: "queued" }
});
return reply.code(202).send({ queued: true });
} catch {
audit(database, {
actorUserId: request.authUser!.id,
action: "system.update",
result: "failure",
ipAddress: clientAddress(request),
details: { reason: "update_service_unavailable" }
});
return reply.code(503).send({
error: { code: "UPDATE_START_FAILED", message: "The update service could not be started" }
});
}
}
);
app.get("/api/events", async (request, reply) => {
if (!requireUser(request, reply)) return;
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no"
});
let closed = false;
const sendStatus = async () => {
if (closed) return;
try {
const status = await collectSystemStatus();
reply.raw.write(`event: status\ndata: ${JSON.stringify(status)}\n\n`);
} catch {
reply.raw.write(`event: collection-error\ndata: {"code":"STATUS_COLLECTION_FAILED"}\n\n`);
}
};
await sendStatus();
const interval = setInterval(() => void sendStatus(), 10_000);
request.raw.on("close", () => {
closed = true;
clearInterval(interval);
});
});
const webRoot = resolve(fileURLToPath(new URL("../../web/dist", import.meta.url)));
if (config.nodeEnv === "production" && existsSync(webRoot)) {
app.get("/", async (_request, reply) => {
reply.type("text/html; charset=utf-8");
return reply.send(createReadStream(resolve(webRoot, "index.html")));
});
app.get<{ Params: { file: string } }>("/assets/:file", async (request, reply) => {
if (!/^[a-zA-Z0-9._-]+$/.test(request.params.file)) {
return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Asset not found" } });
}
const assetPath = resolve(webRoot, "assets", request.params.file);
if (!existsSync(assetPath) || !statSync(assetPath).isFile()) {
return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Asset not found" } });
}
const contentTypes: Record<string, string> = {
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".map": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".woff2": "font/woff2"
};
reply.type(contentTypes[extname(assetPath)] ?? "application/octet-stream");
reply.header("Cache-Control", "public, max-age=31536000, immutable");
return reply.send(createReadStream(assetPath));
});
}
return app;
}
+57
View File
@@ -0,0 +1,57 @@
import { resolve } from "node:path";
import { z } from "zod";
const booleanFromString = z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true");
const schema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
HOST: z.string().default("127.0.0.1"),
PORT: z.coerce.number().int().min(1).max(65_535).default(8787),
DATABASE_PATH: z.string().default(resolve("data/companion.db")),
SESSION_TTL_HOURS: z.coerce.number().positive().max(24 * 30).default(24),
COOKIE_SECURE: booleanFromString,
ALLOWED_ORIGINS: z.string().default("http://mg4pi.local:8787"),
UPDATE_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/update-status.json"),
VERSION_FILE: z.string().default(resolve("REVISION"))
});
export type AppConfig = {
nodeEnv: "development" | "test" | "production";
host: string;
port: number;
databasePath: string;
sessionTtlMs: number;
cookieSecure: boolean;
allowedOrigins: ReadonlySet<string>;
updateStatusPath: string;
versionFile: string;
};
export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppConfig {
const parsed = schema.parse(environment);
const allowedOrigins = new Set(
parsed.ALLOWED_ORIGINS.split(",")
.map((origin) => origin.trim())
.filter(Boolean)
);
if (parsed.NODE_ENV === "development") {
allowedOrigins.add("http://localhost:5173");
allowedOrigins.add("http://127.0.0.1:5173");
}
return {
nodeEnv: parsed.NODE_ENV,
host: parsed.HOST,
port: parsed.PORT,
databasePath: parsed.DATABASE_PATH,
sessionTtlMs: parsed.SESSION_TTL_HOURS * 60 * 60 * 1000,
cookieSecure: parsed.COOKIE_SECURE,
allowedOrigins,
updateStatusPath: parsed.UPDATE_STATUS_PATH,
versionFile: parsed.VERSION_FILE
};
}
+47
View File
@@ -0,0 +1,47 @@
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import Database from "better-sqlite3";
export type CompanionDatabase = Database.Database;
export function openDatabase(path: string): CompanionDatabase {
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true, mode: 0o750 });
const database = new Database(path);
database.pragma("journal_mode = WAL");
database.pragma("foreign_keys = ON");
database.pragma("busy_timeout = 5000");
migrate(database);
return database;
}
function migrate(database: CompanionDatabase): void {
database.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role = 'admin'),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
result TEXT NOT NULL CHECK (result IN ('success', 'failure')),
ip_address TEXT,
details_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
`);
}
+21
View File
@@ -0,0 +1,21 @@
import { buildApp } from "./app.js";
import { loadConfig } from "./config.js";
const config = loadConfig();
const app = await buildApp(config);
const shutdown = async (signal: string) => {
app.log.info({ signal }, "Shutting down");
await app.close();
process.exit(0);
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
try {
await app.listen({ host: config.host, port: config.port });
} catch (error) {
app.log.error(error);
process.exit(1);
}
+23
View File
@@ -0,0 +1,23 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import type { FastifyRequest } from "fastify";
export const SESSION_COOKIE = "companion_session";
export const CSRF_COOKIE = "companion_csrf";
export function randomToken(bytes = 32): string {
return randomBytes(bytes).toString("base64url");
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function safeEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
export function clientAddress(request: FastifyRequest): string {
return request.ip.slice(0, 64);
}
+80
View File
@@ -0,0 +1,80 @@
import { readFile } from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import os from "node:os";
import type { Availability } from "./types.js";
const execFileAsync = promisify(execFile);
type DiskStatus = { totalBytes: number; usedBytes: number; availableBytes: number; mount: string };
function available<T>(value: T): Availability<T> {
return { available: true, value };
}
function unavailable<T>(reason: string): Availability<T> {
return { available: false, reason };
}
async function cpuTemperature(): Promise<Availability<number>> {
try {
const raw = await readFile("/sys/class/thermal/thermal_zone0/temp", "utf8");
const value = Number.parseInt(raw.trim(), 10) / 1000;
return Number.isFinite(value) ? available(value) : unavailable("Kernel returned an invalid value");
} catch {
return unavailable("Thermal sensor is not exposed by this host");
}
}
async function diskUsage(): Promise<Availability<DiskStatus>> {
try {
const { stdout } = await execFileAsync("df", ["-Pk", "/"], { timeout: 2_000 });
const line = stdout.trim().split("\n").at(-1)?.trim().split(/\s+/);
if (!line || line.length < 6) return unavailable("Could not parse disk usage");
const total = Number(line[1]) * 1024;
const used = Number(line[2]) * 1024;
const free = Number(line[3]) * 1024;
if (![total, used, free].every(Number.isFinite)) return unavailable("Disk usage was invalid");
return available({ totalBytes: total, usedBytes: used, availableBytes: free, mount: line[5] ?? "/" });
} catch {
return unavailable("The df utility was unavailable or timed out");
}
}
export async function collectSystemStatus() {
let interfaces: Array<{ name: string; address: string; family: string }> = [];
let interfaceReason: string | null = null;
try {
interfaces = Object.entries(os.networkInterfaces()).flatMap(([name, addresses]) =>
(addresses ?? [])
.filter((address) => !address.internal)
.map((address) => ({ name, address: address.address, family: address.family }))
);
if (interfaces.length === 0) interfaceReason = "No active non-loopback interface was found";
} catch {
interfaceReason = "Network interfaces are not exposed by this host";
}
const totalMemory = os.totalmem();
const freeMemory = os.freemem();
const [temperature, disk] = await Promise.all([cpuTemperature(), diskUsage()]);
return {
collectedAt: new Date().toISOString(),
hostname: os.hostname(),
uptimeSeconds: os.uptime(),
operatingSystem: `${os.type()} ${os.release()}`,
cpu: {
model: os.cpus()[0]?.model ?? "Unavailable",
loadAverage: os.loadavg(),
temperatureCelsius: temperature
},
memory: { totalBytes: totalMemory, usedBytes: totalMemory - freeMemory, availableBytes: freeMemory },
disk,
network: {
interfaces,
interfaceReason,
wifi: unavailable<string>("Wi-Fi capability detection is scheduled for the next milestone")
},
service: { state: "healthy" as const, processUptimeSeconds: process.uptime() }
};
}
+15
View File
@@ -0,0 +1,15 @@
import type { CompanionDatabase } from "./database.js";
declare module "fastify" {
interface FastifyRequest {
authUser: { id: number; username: string; role: "admin" } | null;
}
interface FastifyInstance {
database: CompanionDatabase;
}
}
export type Availability<T> =
| { available: true; value: T }
| { available: false; reason: string };
+65
View File
@@ -0,0 +1,65 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { promisify } from "node:util";
import { z } from "zod";
import type { AppConfig } from "./config.js";
const execFileAsync = promisify(execFile);
const updateStatusSchema = z.object({
state: z.enum(["idle", "checking", "building", "current", "success", "failed"]),
message: z.string().max(500),
fromRevision: z.string().max(64).nullable().default(null),
toRevision: z.string().max(64).nullable().default(null),
updatedAt: z.string()
});
export type UpdateStatus = z.infer<typeof updateStatusSchema> & {
installedRevision: string | null;
supported: boolean;
};
async function readTrimmed(path: string): Promise<string | null> {
try {
const value = (await readFile(path, "utf8")).trim();
return value || null;
} catch {
return null;
}
}
export async function readUpdateStatus(config: AppConfig): Promise<UpdateStatus> {
const installedRevision = await readTrimmed(config.versionFile);
try {
const parsed = updateStatusSchema.parse(JSON.parse(await readFile(config.updateStatusPath, "utf8")));
const updateAgeMs = Date.now() - Date.parse(parsed.updatedAt);
if (["checking", "building"].includes(parsed.state) && Number.isFinite(updateAgeMs) && updateAgeMs > 35 * 60 * 1000) {
return {
...parsed,
state: "failed",
message: "The previous update was interrupted. It is safe to try again.",
installedRevision,
supported: true
};
}
return { ...parsed, installedRevision, supported: true };
} catch {
return {
state: "idle",
message: "Automatic updates are available after installing the system service.",
fromRevision: null,
toRevision: null,
updatedAt: new Date(0).toISOString(),
installedRevision,
supported: false
};
}
}
export async function triggerSystemUpdate(): Promise<void> {
await execFileAsync(
"/usr/bin/sudo",
["-n", "/usr/bin/systemctl", "start", "--no-block", "pi-car-companion-update.service"],
{ timeout: 10_000, maxBuffer: 16 * 1024 }
);
}
+157
View File
@@ -0,0 +1,157 @@
import { afterEach, describe, expect, it } from "vitest";
import type { FastifyInstance } from "fastify";
import { buildApp } from "../src/app.js";
import type { AppConfig } from "../src/config.js";
import { CSRF_COOKIE, SESSION_COOKIE } from "../src/security.js";
const apps: FastifyInstance[] = [];
function testConfig(): AppConfig {
return {
nodeEnv: "test",
host: "127.0.0.1",
port: 8787,
databasePath: ":memory:",
sessionTtlMs: 60_000,
cookieSecure: false,
allowedOrigins: new Set(["http://mg4pi.local:8787"]),
updateStatusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-update.json`,
versionFile: `/tmp/pi-car-companion-test-${process.pid}-missing-revision`
};
}
async function createApp(): Promise<FastifyInstance> {
const app = await buildApp(testConfig());
apps.push(app);
return app;
}
async function csrf(app: FastifyInstance): Promise<string> {
const response = await app.inject({ method: "GET", url: "/api/auth/state" });
expect(response.statusCode).toBe(200);
return response.json<{ csrfToken: string }>().csrfToken;
}
function mutationHeaders(token: string) {
return {
cookie: `${CSRF_COOKIE}=${token}`,
"x-csrf-token": token,
origin: "http://mg4pi.local:8787"
};
}
async function setup(app: FastifyInstance, token: string) {
return app.inject({
method: "POST",
url: "/api/auth/setup",
headers: mutationHeaders(token),
payload: { username: "owner", password: "correct-horse-battery-staple" }
});
}
afterEach(async () => {
await Promise.all(apps.splice(0).map((app) => app.close()));
});
describe("authentication boundary", () => {
it("creates exactly one initial administrator", async () => {
const app = await createApp();
const token = await csrf(app);
expect((await setup(app, token)).statusCode).toBe(201);
const second = await app.inject({
method: "POST",
url: "/api/auth/setup",
headers: mutationHeaders(token),
payload: { username: "second", password: "another-secure-password" }
});
expect(second.statusCode).toBe(409);
expect(second.json()).toMatchObject({ error: { code: "SETUP_COMPLETE" } });
});
it("rejects state changes without a matching CSRF token", async () => {
const app = await createApp();
const response = await app.inject({
method: "POST",
url: "/api/auth/setup",
payload: { username: "owner", password: "correct-horse-battery-staple" }
});
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({ error: { code: "CSRF_REJECTED" } });
});
it("rejects an untrusted browser origin", async () => {
const app = await createApp();
const token = await csrf(app);
const response = await app.inject({
method: "POST",
url: "/api/auth/setup",
headers: { ...mutationHeaders(token), origin: "https://attacker.example" },
payload: { username: "owner", password: "correct-horse-battery-staple" }
});
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({ error: { code: "ORIGIN_REJECTED" } });
});
it("accepts a same-host origin for dynamic LAN addresses", async () => {
const app = await createApp();
const token = await csrf(app);
const response = await app.inject({
method: "POST",
url: "/api/auth/setup",
headers: {
...mutationHeaders(token),
host: "192.168.4.20:8787",
origin: "http://192.168.4.20:8787"
},
payload: { username: "owner", password: "correct-horse-battery-staple" }
});
expect(response.statusCode).toBe(201);
});
it("requires a valid session for system status", async () => {
const app = await createApp();
expect((await app.inject({ method: "GET", url: "/api/status" })).statusCode).toBe(401);
const token = await csrf(app);
await setup(app, token);
const login = await app.inject({
method: "POST",
url: "/api/auth/login",
headers: mutationHeaders(token),
payload: { username: "owner", password: "correct-horse-battery-staple" }
});
expect(login.statusCode).toBe(200);
const sessionCookie = login.cookies.find((cookie) => cookie.name === SESSION_COOKIE);
expect(sessionCookie).toBeDefined();
const status = await app.inject({
method: "GET",
url: "/api/status",
headers: { cookie: `${SESSION_COOKIE}=${sessionCookie?.value ?? ""}` }
});
expect(status.statusCode, status.body).toBe(200);
expect(status.json()).toMatchObject({ service: { state: "healthy" } });
const update = await app.inject({
method: "POST",
url: "/api/system/update",
headers: {
...mutationHeaders(token),
cookie: `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${sessionCookie?.value ?? ""}`
}
});
expect(update.statusCode).toBe(503);
expect(update.json()).toMatchObject({ error: { code: "UPDATES_UNAVAILABLE" } });
});
it("does not expose update state without authentication", async () => {
const app = await createApp();
const response = await app.inject({ method: "GET", url: "/api/system/update" });
expect(response.statusCode).toBe(401);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { AppConfig } from "../src/config.js";
import { readUpdateStatus } from "../src/update.js";
const temporaryDirectories: string[] = [];
function config(updateStatusPath: string, versionFile: string): AppConfig {
return {
nodeEnv: "test",
host: "127.0.0.1",
port: 8787,
databasePath: ":memory:",
sessionTtlMs: 60_000,
cookieSecure: false,
allowedOrigins: new Set(),
updateStatusPath,
versionFile
};
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe("system update status", () => {
it("reads only validated updater state and active revision metadata", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-car-update-test-"));
temporaryDirectories.push(directory);
const statusPath = join(directory, "status.json");
const versionPath = join(directory, "REVISION");
await writeFile(
statusPath,
JSON.stringify({
state: "success",
message: "Update installed.",
fromRevision: "a".repeat(40),
toRevision: "b".repeat(40),
updatedAt: "2026-07-31T18:00:00.000Z"
})
);
await writeFile(versionPath, `${"b".repeat(40)}\n`);
await expect(readUpdateStatus(config(statusPath, versionPath))).resolves.toMatchObject({
state: "success",
installedRevision: "b".repeat(40),
supported: true
});
});
it("treats missing or malformed status files as unsupported", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-car-update-test-"));
temporaryDirectories.push(directory);
const statusPath = join(directory, "status.json");
await writeFile(statusPath, "not-json");
await expect(readUpdateStatus(config(statusPath, join(directory, "missing")))).resolves.toMatchObject({
state: "idle",
installedRevision: null,
supported: false
});
});
it("recovers an update state left running after interruption", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-car-update-test-"));
temporaryDirectories.push(directory);
const statusPath = join(directory, "status.json");
await writeFile(
statusPath,
JSON.stringify({
state: "building",
message: "Building.",
fromRevision: null,
toRevision: null,
updatedAt: "2020-01-01T00:00:00.000Z"
})
);
await expect(readUpdateStatus(config(statusPath, join(directory, "missing")))).resolves.toMatchObject({
state: "failed",
supported: true
});
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"declaration": true
},
"include": ["src"],
"exclude": ["tests"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node", "vitest/globals"],
"noEmit": true
},
"include": ["src", "tests"]
}