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',
|
2026-06-19 15:25:10 +00:00
|
|
|
'remote_desktop',
|
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) {
|
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
|
|
|
const secretKeys = (
|
|
|
|
|
db.prepare('SELECT key FROM secrets WHERE integration_id = ?').all(row.id) as { key: string }[]
|
|
|
|
|
).map((r) => r.key)
|
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 {
|
|
|
|
|
id: row.id,
|
|
|
|
|
type: row.type,
|
|
|
|
|
name: row.name,
|
|
|
|
|
enabled: !!row.enabled,
|
|
|
|
|
status: row.status,
|
|
|
|
|
config: JSON.parse(row.config_json),
|
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
|
|
|
secretKeys,
|
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
|
|
|
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
|
|
|
}
|