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
2026-06-18 19:04:48 +00:00
|
|
|
import type { FastifyInstance } from 'fastify'
|
|
|
|
|
import { z } from 'zod'
|
2026-06-18 19:56:10 +00:00
|
|
|
import { db, logEvent } from '../db/index.js'
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
import { encryptSecret } from '../db/crypto.js'
|
|
|
|
|
import { loadSecrets } from '../db/secrets.js'
|
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
2026-06-18 19:04:48 +00:00
|
|
|
import { adapterRegistry } from '../integrations/registry.js'
|
2026-06-18 19:56:10 +00:00
|
|
|
import type { IntegrationType, Resource } from '../integrations/types.js'
|
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
2026-06-18 19:04:48 +00:00
|
|
|
|
|
|
|
|
const integrationTypes = [
|
|
|
|
|
'proxmox',
|
|
|
|
|
'docker',
|
|
|
|
|
'netbird',
|
|
|
|
|
'cloudflare',
|
|
|
|
|
'aws',
|
|
|
|
|
'uptime_kuma',
|
|
|
|
|
'weather',
|
2026-06-18 21:06:16 +00:00
|
|
|
'ssh',
|
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
2026-06-18 19:04:48 +00:00
|
|
|
] 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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-18 19:56:10 +00:00
|
|
|
logEvent('integration_created', `${name} integration added`, type)
|
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
2026-06-18 19:04:48 +00:00
|
|
|
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)
|
2026-06-18 19:56:10 +00:00
|
|
|
const existing = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as IntegrationRow | undefined
|
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
2026-06-18 19:04:48 +00:00
|
|
|
db.prepare('DELETE FROM integrations WHERE id = ?').run(id)
|
2026-06-18 19:56:10 +00:00
|
|
|
if (existing) logEvent('integration_deleted', `${existing.name} integration removed`, existing.type)
|
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
2026-06-18 19:04:48 +00:00
|
|
|
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
|
|
|
|
|
)
|
2026-06-18 19:56:10 +00:00
|
|
|
logEvent('integration_tested', `${row.name} test ${result.ok ? 'succeeded' : 'failed'}`, row.type)
|
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
2026-06-18 19:04:48 +00:00
|
|
|
return result
|
|
|
|
|
})
|
2026-06-18 19:56:10 +00:00
|
|
|
|
|
|
|
|
app.get('/api/integrations/resources', async () => {
|
|
|
|
|
const rows = db.prepare("SELECT * FROM integrations WHERE enabled = 1 AND status = 'connected'").all() as IntegrationRow[]
|
|
|
|
|
const resources: (Resource & { integration: string })[] = []
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
const adapter = adapterRegistry[row.type as IntegrationType]
|
|
|
|
|
if (!adapter.listResources) continue
|
|
|
|
|
const config = JSON.parse(row.config_json)
|
|
|
|
|
const secrets = loadSecrets(row.id)
|
|
|
|
|
try {
|
|
|
|
|
const found = await adapter.listResources(config, secrets)
|
|
|
|
|
for (const r of found) resources.push({ ...r, integration: row.name })
|
|
|
|
|
} catch {
|
|
|
|
|
// adapter unreachable — skip, connection test already surfaces this
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { resources }
|
|
|
|
|
})
|
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
2026-06-18 19:04:48 +00:00
|
|
|
}
|