Add backend skeleton: Fastify + SQLite API with auth and integrations

- Single-user JWT auth with a first-run /api/setup endpoint, gated by
  GET /api/system/setup-status, to back an upcoming enrollment page
- SQLite schema for users, integrations, secrets (AES-256-GCM encrypted),
  bookmarks, and bookmark categories
- Integration adapter registry with real health-check adapters for
  Uptime Kuma and Docker, stubs for the rest, wired to
  POST /api/integrations/:id/test
- CRUD routes for integrations and bookmarks
- backend/ as its own Docker service in docker-compose.yml, Vite dev
  proxy for /api, .env.example for required secrets

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
Claude 2026-06-18 19:04:48 +00:00
parent f24edb74b2
commit a0b71c7028
No known key found for this signature in database
20 changed files with 2476 additions and 0 deletions

9
.gitignore vendored
View file

@ -12,6 +12,15 @@ dist
dist-ssr
*.local
# Backend data/secrets
backend/data
backend/.env
*.db
*.db-journal
*.db-wal
*.db-shm
*.tsbuildinfo
# Editor directories and files
.vscode/*
!.vscode/extensions.json

5
backend/.env.example Normal file
View file

@ -0,0 +1,5 @@
PORT=4000
ARCHNEST_DB_PATH=./data/archnest.db
ARCHNEST_JWT_SECRET=change-me-to-a-long-random-string
ARCHNEST_SECRET_KEY=change-me-to-another-long-random-string
ARCHNEST_CORS_ORIGIN=http://localhost:5173

15
backend/Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev=false
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 4000
CMD ["node", "dist/server.js"]

1865
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
backend/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "archnest-backend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -b",
"start": "node dist/server.js"
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/jwt": "^9.0.4",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.8.1",
"dotenv": "^16.6.1",
"fastify": "^5.2.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.5",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

25
backend/src/db/crypto.ts Normal file
View file

@ -0,0 +1,25 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'
const rawKey = process.env.ARCHNEST_SECRET_KEY
if (!rawKey) {
throw new Error('ARCHNEST_SECRET_KEY env var is required to encrypt integration secrets')
}
const key = createHash('sha256').update(rawKey).digest()
export function encryptSecret(plaintext: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
return Buffer.concat([iv, authTag, encrypted]).toString('base64')
}
export function decryptSecret(payload: string): string {
const buf = Buffer.from(payload, 'base64')
const iv = buf.subarray(0, 12)
const authTag = buf.subarray(12, 28)
const encrypted = buf.subarray(28)
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(authTag)
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
}

60
backend/src/db/index.ts Normal file
View file

@ -0,0 +1,60 @@
import Database from 'better-sqlite3'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
const DB_PATH = process.env.ARCHNEST_DB_PATH ?? './data/archnest.db'
mkdirSync(dirname(DB_PATH), { recursive: true })
export const db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
email TEXT,
avatar_data_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS integrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'unknown',
config_json TEXT NOT NULL DEFAULT '{}',
last_checked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
integration_id INTEGER NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value_encrypted TEXT NOT NULL,
UNIQUE(integration_id, key)
);
CREATE TABLE IF NOT EXISTS bookmark_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
icon TEXT,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER REFERENCES bookmark_categories(id) ON DELETE SET NULL,
title TEXT NOT NULL,
url TEXT NOT NULL,
icon TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown',
last_checked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)

View file

@ -0,0 +1,15 @@
import type { IntegrationAdapter } from './types.js'
export const docker: IntegrationAdapter = {
async testConnection(config) {
const baseUrl = config.baseUrl?.replace(/\/$/, '')
if (!baseUrl) return { ok: false, message: 'Missing baseUrl' }
try {
const res = await fetch(`${baseUrl}/version`)
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
return { ok: true, message: 'Connected' }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' }
}
},
}

View file

@ -0,0 +1,19 @@
import type { IntegrationAdapter, IntegrationType } from './types.js'
import { uptimeKuma } from './uptimeKuma.js'
import { docker } from './docker.js'
const notImplemented: IntegrationAdapter = {
async testConnection() {
return { ok: false, message: 'Test connection not yet implemented for this integration type' }
},
}
export const adapterRegistry: Record<IntegrationType, IntegrationAdapter> = {
uptime_kuma: uptimeKuma,
docker,
proxmox: notImplemented,
netbird: notImplemented,
cloudflare: notImplemented,
aws: notImplemented,
weather: notImplemented,
}

View file

@ -0,0 +1,21 @@
export type IntegrationType =
| 'proxmox'
| 'docker'
| 'netbird'
| 'cloudflare'
| 'aws'
| 'uptime_kuma'
| 'weather'
export interface IntegrationConfig {
[key: string]: string
}
export interface TestResult {
ok: boolean
message: string
}
export interface IntegrationAdapter {
testConnection(config: IntegrationConfig, secrets: Record<string, string>): Promise<TestResult>
}

View file

@ -0,0 +1,15 @@
import type { IntegrationAdapter } from './types.js'
export const uptimeKuma: IntegrationAdapter = {
async testConnection(config) {
const baseUrl = config.baseUrl?.replace(/\/$/, '')
if (!baseUrl) return { ok: false, message: 'Missing baseUrl' }
try {
const res = await fetch(`${baseUrl}/api/status-page/heartbeat/default`)
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
return { ok: true, message: 'Connected' }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' }
}
},
}

View file

@ -0,0 +1,58 @@
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { db } from '../db/index.js'
const credentialsSchema = z.object({
username: z.string().min(3).max(64),
password: z.string().min(8).max(256),
})
export async function authRoutes(app: FastifyInstance) {
app.get('/api/system/setup-status', async () => {
const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }
return { needsSetup: row.count === 0 }
})
app.post('/api/setup', async (req, reply) => {
const existing = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }
if (existing.count > 0) {
return reply.code(409).send({ error: 'Setup already completed' })
}
const parsed = credentialsSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
}
const { username, password } = parsed.data
const passwordHash = await bcrypt.hash(password, 12)
const result = db
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
.run(username, passwordHash)
const token = app.jwt.sign({ sub: Number(result.lastInsertRowid), username })
return { token }
})
app.post('/api/auth/login', async (req, reply) => {
const parsed = credentialsSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'Invalid input' })
}
const { username, password } = parsed.data
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
| { id: number; username: string; password_hash: string }
| undefined
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
return reply.code(401).send({ error: 'Invalid username or password' })
}
const token = app.jwt.sign({ sub: user.id, username: user.username })
return { token }
})
app.get('/api/auth/me', { onRequest: [app.authenticate] }, async (req) => {
const payload = req.user as { sub: number; username: string }
const user = db
.prepare('SELECT id, username, display_name, email, avatar_data_url FROM users WHERE id = ?')
.get(payload.sub)
return { user }
})
}

View file

@ -0,0 +1,78 @@
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { db } from '../db/index.js'
const bookmarkSchema = z.object({
categoryId: z.number().int().nullable().optional(),
title: z.string().min(1).max(128),
url: z.string().url(),
icon: z.string().optional(),
favorite: z.boolean().optional(),
})
const categorySchema = z.object({
name: z.string().min(1).max(64),
icon: z.string().optional(),
sortOrder: z.number().int().optional(),
})
export async function bookmarkRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate)
app.get('/api/bookmarks/categories', async () => {
const categories = db.prepare('SELECT * FROM bookmark_categories ORDER BY sort_order').all()
return { categories }
})
app.post('/api/bookmarks/categories', async (req, reply) => {
const parsed = categorySchema.safeParse(req.body)
if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' })
const { name, icon, sortOrder } = parsed.data
const result = db
.prepare('INSERT INTO bookmark_categories (name, icon, sort_order) VALUES (?, ?, ?)')
.run(name, icon ?? null, sortOrder ?? 0)
return reply.code(201).send({ id: result.lastInsertRowid })
})
app.get('/api/bookmarks', async () => {
const bookmarks = db.prepare('SELECT * FROM bookmarks ORDER BY created_at DESC').all()
return { bookmarks }
})
app.post('/api/bookmarks', async (req, reply) => {
const parsed = bookmarkSchema.safeParse(req.body)
if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' })
const { categoryId, title, url, icon, favorite } = parsed.data
const result = db
.prepare(
'INSERT INTO bookmarks (category_id, title, url, icon, favorite) VALUES (?, ?, ?, ?, ?)'
)
.run(categoryId ?? null, title, url, icon ?? null, favorite ? 1 : 0)
return reply.code(201).send({ id: result.lastInsertRowid })
})
app.put('/api/bookmarks/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const parsed = bookmarkSchema.partial().safeParse(req.body)
if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' })
const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id) as
| { category_id: number | null; title: string; url: string; icon: string | null; favorite: number }
| undefined
if (!existing) return reply.code(404).send({ error: 'Not found' })
const categoryId = parsed.data.categoryId ?? existing.category_id
const title = parsed.data.title ?? existing.title
const url = parsed.data.url ?? existing.url
const icon = parsed.data.icon ?? existing.icon
const favorite = parsed.data.favorite ?? !!existing.favorite
db.prepare(
'UPDATE bookmarks SET category_id = ?, title = ?, url = ?, icon = ?, favorite = ? WHERE id = ?'
).run(categoryId, title, url, icon, favorite ? 1 : 0, id)
return { ok: true }
})
app.delete('/api/bookmarks/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id)
return reply.code(204).send()
})
}

View file

@ -0,0 +1,141 @@
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { db } from '../db/index.js'
import { encryptSecret, decryptSecret } from '../db/crypto.js'
import { adapterRegistry } from '../integrations/registry.js'
import type { IntegrationType } from '../integrations/types.js'
const integrationTypes = [
'proxmox',
'docker',
'netbird',
'cloudflare',
'aws',
'uptime_kuma',
'weather',
] as const
const createSchema = z.object({
type: z.enum(integrationTypes),
name: z.string().min(1).max(128),
config: z.record(z.string(), z.string()).default({}),
secrets: z.record(z.string(), z.string()).default({}),
})
interface IntegrationRow {
id: number
type: string
name: string
enabled: number
status: string
config_json: string
last_checked_at: string | null
created_at: string
}
function serialize(row: IntegrationRow) {
return {
id: row.id,
type: row.type,
name: row.name,
enabled: !!row.enabled,
status: row.status,
config: JSON.parse(row.config_json),
lastCheckedAt: row.last_checked_at,
createdAt: row.created_at,
}
}
function loadSecrets(integrationId: number): Record<string, string> {
const rows = db
.prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?')
.all(integrationId) as { key: string; value_encrypted: string }[]
const out: Record<string, string> = {}
for (const row of rows) out[row.key] = decryptSecret(row.value_encrypted)
return out
}
export async function integrationRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate)
app.get('/api/integrations', async () => {
const rows = db.prepare('SELECT * FROM integrations ORDER BY created_at').all() as IntegrationRow[]
return { integrations: rows.map(serialize) }
})
app.post('/api/integrations', async (req, reply) => {
const parsed = createSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
}
const { type, name, config, secrets } = parsed.data
const result = db
.prepare('INSERT INTO integrations (type, name, config_json) VALUES (?, ?, ?)')
.run(type, name, JSON.stringify(config))
const integrationId = Number(result.lastInsertRowid)
const insertSecret = db.prepare(
'INSERT INTO secrets (integration_id, key, value_encrypted) VALUES (?, ?, ?)'
)
for (const [key, value] of Object.entries(secrets)) {
insertSecret.run(integrationId, key, encryptSecret(value))
}
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(integrationId) as IntegrationRow
return reply.code(201).send({ integration: serialize(row) })
})
app.put('/api/integrations/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const parsed = createSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
}
const existing = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as
| IntegrationRow
| undefined
if (!existing) return reply.code(404).send({ error: 'Not found' })
const name = parsed.data.name ?? existing.name
const config = parsed.data.config ?? JSON.parse(existing.config_json)
db.prepare('UPDATE integrations SET name = ?, config_json = ? WHERE id = ?').run(
name,
JSON.stringify(config),
id
)
if (parsed.data.secrets) {
const upsert = db.prepare(
`INSERT INTO secrets (integration_id, key, value_encrypted) VALUES (?, ?, ?)
ON CONFLICT(integration_id, key) DO UPDATE SET value_encrypted = excluded.value_encrypted`
)
for (const [key, value] of Object.entries(parsed.data.secrets)) {
upsert.run(id, key, encryptSecret(value))
}
}
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as IntegrationRow
return { integration: serialize(row) }
})
app.delete('/api/integrations/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
db.prepare('DELETE FROM integrations WHERE id = ?').run(id)
return reply.code(204).send()
})
app.post('/api/integrations/:id/test', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as
| IntegrationRow
| undefined
if (!row) return reply.code(404).send({ error: 'Not found' })
const adapter = adapterRegistry[row.type as IntegrationType]
const config = JSON.parse(row.config_json)
const secrets = loadSecrets(id)
const result = await adapter.testConnection(config, secrets)
const status = result.ok ? 'connected' : 'error'
db.prepare("UPDATE integrations SET status = ?, last_checked_at = datetime('now') WHERE id = ?").run(
status,
id
)
return result
})
}

37
backend/src/server.ts Normal file
View file

@ -0,0 +1,37 @@
import 'dotenv/config'
import Fastify from 'fastify'
import cors from '@fastify/cors'
import jwt from '@fastify/jwt'
import { authRoutes } from './routes/auth.js'
import { integrationRoutes } from './routes/integrations.js'
import { bookmarkRoutes } from './routes/bookmarks.js'
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
if (!JWT_SECRET) {
throw new Error('ARCHNEST_JWT_SECRET env var is required')
}
const app = Fastify({ logger: true })
await app.register(cors, { origin: process.env.ARCHNEST_CORS_ORIGIN ?? true })
await app.register(jwt, { secret: JWT_SECRET })
app.decorate('authenticate', async function (req, reply) {
try {
await req.jwtVerify()
} catch {
reply.code(401).send({ error: 'Unauthorized' })
}
})
await app.register(authRoutes)
await app.register(integrationRoutes)
await app.register(bookmarkRoutes)
app.get('/api/health', async () => ({ ok: true }))
const port = Number(process.env.PORT ?? 4000)
app.listen({ port, host: '0.0.0.0' }).catch((err) => {
app.log.error(err)
process.exit(1)
})

14
backend/src/types.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
import '@fastify/jwt'
declare module 'fastify' {
interface FastifyInstance {
authenticate: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: { sub: number; username: string }
user: { sub: number; username: string }
}
}

15
backend/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false
},
"include": ["src"]
}

View file

@ -181,6 +181,34 @@
---
## Backend (added once frontend reached "good enough" state)
- New `backend/` package (separate `package.json`/`tsconfig.json`, own `node_modules`,
own Dockerfile) — Fastify + TypeScript, `better-sqlite3` for storage, deployed as a
second container alongside the existing frontend container (`docker-compose.yml` now
has `archnest` + `archnest-backend`, with a named volume for the SQLite file).
- Auth: single-user, JWT-based (`@fastify/jwt`). `POST /api/setup` creates the one user
and only succeeds while the `users` table is empty — this is what powers the
first-run enrollment page. `GET /api/system/setup-status` tells the frontend whether
to show enrollment/login or the normal app.
- Integration credentials are split across two tables: `integrations` (type, name,
status, non-secret `config_json`) and `secrets` (per-key AES-256-GCM-encrypted
values, key derived from the `ARCHNEST_SECRET_KEY` env var) — keeps secrets out of any
generic "list integrations" query/response by construction, not by remembering to
redact a field.
- Each integration type has an adapter module under `backend/src/integrations/`
exporting `testConnection(config, secrets)`; `registry.ts` maps `IntegrationType`
adapter. Only `uptime_kuma` and `docker` are real so far (simple HTTP health checks);
the rest return a "not yet implemented" result until built out — this lets the
Integrations UI and `POST /api/integrations/:id/test` endpoint work end-to-end for
every type without blocking on every adapter being finished.
- Vite dev server proxies `/api``http://localhost:4000` (`vite.config.ts`) so the
frontend can call relative `/api/...` paths in both dev and prod (prod routes `/api`
to the backend container via NPM).
- Next steps (not yet done): build the enrollment/login frontend pages, strip the mock
arrays out of Glance/Infrastructure/BookNest/Settings and replace with calls to this
API, add bookmark category seeding.
## Future Integration Notes
### Live Provider Data (AWS, Linode, etc.)

View file

@ -6,3 +6,24 @@ services:
restart: unless-stopped
ports:
- "8080:8080"
depends_on:
- archnest-backend
archnest-backend:
build: ./backend
image: archnest-backend:latest
container_name: archnest-backend
restart: unless-stopped
environment:
- PORT=4000
- ARCHNEST_DB_PATH=/data/archnest.db
- ARCHNEST_JWT_SECRET=${ARCHNEST_JWT_SECRET}
- ARCHNEST_SECRET_KEY=${ARCHNEST_SECRET_KEY}
- ARCHNEST_CORS_ORIGIN=${ARCHNEST_CORS_ORIGIN:-https://archnest.snsnetlabs.com}
volumes:
- archnest-data:/data
ports:
- "4000:4000"
volumes:
archnest-data:

View file

@ -4,4 +4,12 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
})