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:
parent
f24edb74b2
commit
a0b71c7028
20 changed files with 2476 additions and 0 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -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
5
backend/.env.example
Normal 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
15
backend/Dockerfile
Normal 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
1865
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
backend/package.json
Normal file
27
backend/package.json
Normal 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
25
backend/src/db/crypto.ts
Normal 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
60
backend/src/db/index.ts
Normal 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'))
|
||||
);
|
||||
`)
|
||||
15
backend/src/integrations/docker.ts
Normal file
15
backend/src/integrations/docker.ts
Normal 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' }
|
||||
}
|
||||
},
|
||||
}
|
||||
19
backend/src/integrations/registry.ts
Normal file
19
backend/src/integrations/registry.ts
Normal 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,
|
||||
}
|
||||
21
backend/src/integrations/types.ts
Normal file
21
backend/src/integrations/types.ts
Normal 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>
|
||||
}
|
||||
15
backend/src/integrations/uptimeKuma.ts
Normal file
15
backend/src/integrations/uptimeKuma.ts
Normal 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' }
|
||||
}
|
||||
},
|
||||
}
|
||||
58
backend/src/routes/auth.ts
Normal file
58
backend/src/routes/auth.ts
Normal 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 }
|
||||
})
|
||||
}
|
||||
78
backend/src/routes/bookmarks.ts
Normal file
78
backend/src/routes/bookmarks.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
141
backend/src/routes/integrations.ts
Normal file
141
backend/src/routes/integrations.ts
Normal 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
37
backend/src/server.ts
Normal 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
14
backend/src/types.d.ts
vendored
Normal 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
15
backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -4,4 +4,12 @@ import tailwindcss from '@tailwindcss/vite'
|
|||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue