commit e7b08ccf3165dbe42a52d6421fac2b5d564d3303 Author: jtricerolph Date: Wed Jul 1 18:56:19 2026 +0000 Initial settings service Fastify API for storing third-party integration credentials (Newbook, Resos, Nextcloud, Azure, SambaPOS) with AES-256-GCM encryption for sensitive fields. Includes Newbook room sync endpoint and global_config store for shared app data. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e790b49 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL=postgresql://postgres:password@10.10.10.100:5432/settings +SETTINGS_SECRET=change-me-to-a-long-random-string +AUTH_URL=http://10.10.10.101:3001 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3ff5900 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY src/ ./src/ +EXPOSE 3080 +CMD ["node", "src/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0aef327 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + settings: + build: . + restart: unless-stopped + ports: + - "3080:3080" + environment: + DATABASE_URL: ${DATABASE_URL} + SETTINGS_SECRET: ${SETTINGS_SECRET} + AUTH_URL: ${AUTH_URL:-http://10.10.10.101:3001} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ab3602f --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "hnf-settings", + "version": "1.0.0", + "type": "module", + "main": "src/index.js", + "dependencies": { + "@fastify/cors": "^9.0.1", + "fastify": "^4.28.1", + "pg": "^8.12.0" + } +} diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..cc1ccd2 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,35 @@ +const AUTH_URL = process.env.AUTH_URL || 'http://10.10.10.101:3001' + +async function verify(request, reply) { + const cookie = request.headers.cookie || '' + let res + try { + res = await fetch(`${AUTH_URL}/api/auth/verify`, { + headers: { cookie }, + signal: AbortSignal.timeout(3000), + }) + } catch { + reply.status(503).send({ error: 'Auth service unreachable' }) + return null + } + if (!res.ok) { + reply.status(401).send({ error: 'Not authenticated' }) + return null + } + return res.json() +} + +export async function requireAuth(request, reply) { + const user = await verify(request, reply) + if (user) request.authUser = user +} + +export async function requireAdmin(request, reply) { + const user = await verify(request, reply) + if (!user) return + if (!user.is_admin) { + reply.status(403).send({ error: 'Admin only' }) + return + } + request.authUser = user +} diff --git a/src/crypto.js b/src/crypto.js new file mode 100644 index 0000000..67689b0 --- /dev/null +++ b/src/crypto.js @@ -0,0 +1,28 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto' + +const ALG = 'aes-256-gcm' +const IV_LEN = 12 +const TAG_LEN = 16 + +function key() { + const s = process.env.SETTINGS_SECRET + if (!s) throw new Error('SETTINGS_SECRET env var not set') + return scryptSync(s, 'hnf-settings-v1', 32) +} + +export function encrypt(plaintext) { + const iv = randomBytes(IV_LEN) + const c = createCipheriv(ALG, key(), iv) + const enc = Buffer.concat([c.update(plaintext, 'utf8'), c.final()]) + return Buffer.concat([iv, c.getAuthTag(), enc]).toString('base64') +} + +export function decrypt(b64) { + const buf = Buffer.from(b64, 'base64') + const iv = buf.subarray(0, IV_LEN) + const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN) + const enc = buf.subarray(IV_LEN + TAG_LEN) + const d = createDecipheriv(ALG, key(), iv) + d.setAuthTag(tag) + return Buffer.concat([d.update(enc), d.final()]).toString('utf8') +} diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..366a820 --- /dev/null +++ b/src/db.js @@ -0,0 +1,33 @@ +import pg from 'pg' +import { INTEGRATIONS } from './integrations/schema.js' + +const { Pool } = pg +export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +export async function initDb() { + await pool.query(` + CREATE TABLE IF NOT EXISTS integrations ( + slug TEXT PRIMARY KEY, + name TEXT NOT NULL, + config JSONB NOT NULL DEFAULT '{}', + secrets TEXT, + enabled BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS global_config ( + key TEXT PRIMARY KEY, + value JSONB, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `) + + // Seed known integrations (idempotent — never overwrites existing config/secrets) + const slugs = Object.entries(INTEGRATIONS).map(([slug, { name }]) => ({ slug, name })) + for (const { slug, name } of slugs) { + await pool.query( + `INSERT INTO integrations (slug, name) VALUES ($1, $2) ON CONFLICT (slug) DO NOTHING`, + [slug, name] + ) + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..bd761d4 --- /dev/null +++ b/src/index.js @@ -0,0 +1,25 @@ +import Fastify from 'fastify' +import cors from '@fastify/cors' +import { initDb } from './db.js' +import { integrationRoutes } from './routes/integrations.js' +import { configRoutes } from './routes/config.js' + +const app = Fastify({ logger: true, trustProxy: true }) + +await app.register(cors, { + origin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : false, + credentials: true, +}) + +app.get('/health', async () => ({ status: 'healthy' })) + +await app.register(integrationRoutes, { prefix: '/settings/api' }) +await app.register(configRoutes, { prefix: '/settings/api' }) + +try { + await initDb() + await app.listen({ port: 3080, host: '0.0.0.0' }) +} catch (err) { + app.log.error(err) + process.exit(1) +} diff --git a/src/integrations/newbook.js b/src/integrations/newbook.js new file mode 100644 index 0000000..0855f41 --- /dev/null +++ b/src/integrations/newbook.js @@ -0,0 +1,27 @@ +const BASE = 'https://api.newbook.cloud/rest' + +async function call(endpoint, body, { api_key, username, password, region }) { + const auth = Buffer.from(`${username}:${password}`).toString('base64') + const res = await fetch(`${BASE}/${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${auth}`, + }, + body: JSON.stringify({ api_key, region, ...body }), + signal: AbortSignal.timeout(10000), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Newbook ${endpoint} → HTTP ${res.status}: ${text.slice(0, 200)}`) + } + return res.json() +} + +export async function testConnection(creds) { + await call('site_list', {}, creds) +} + +export async function fetchSiteList(creds) { + return call('site_list', {}, creds) +} diff --git a/src/integrations/resos.js b/src/integrations/resos.js new file mode 100644 index 0000000..49dc096 --- /dev/null +++ b/src/integrations/resos.js @@ -0,0 +1,13 @@ +const BASE = 'https://api.resos.com/v1' + +function authHeader({ api_key }) { + return `Basic ${Buffer.from(`${api_key}:`).toString('base64')}` +} + +export async function testConnection(creds) { + const res = await fetch(`${BASE}/openingHours?showDeleted=false&onlySpecial=false`, { + headers: { Authorization: authHeader(creds) }, + signal: AbortSignal.timeout(10000), + }) + if (!res.ok) throw new Error(`Resos → HTTP ${res.status}`) +} diff --git a/src/integrations/schema.js b/src/integrations/schema.js new file mode 100644 index 0000000..b26e4a2 --- /dev/null +++ b/src/integrations/schema.js @@ -0,0 +1,32 @@ +// Field definitions per integration. +// configFields — stored plaintext in the config JSONB column +// secretFields — encrypted in the secrets column (api keys, passwords) +export const INTEGRATIONS = { + newbook: { + name: 'Newbook PMS', + configFields: ['region', 'username'], + secretFields: ['api_key', 'password'], + }, + resos: { + name: 'Resos Reservations', + configFields: [], + secretFields: ['api_key'], + }, + nextcloud: { + name: 'Nextcloud', + configFields: ['base_url', 'username'], + secretFields: ['password'], + }, + azure: { + name: 'Azure AD', + configFields: ['tenant_id', 'client_id'], + secretFields: ['client_secret'], + }, + sambapos: { + name: 'SambaPOS', + configFields: ['host', 'port', 'database', 'graphql_endpoint', 'username'], + secretFields: ['password'], + }, +} + +export const MASKED = '••••••••' diff --git a/src/routes/config.js b/src/routes/config.js new file mode 100644 index 0000000..7213134 --- /dev/null +++ b/src/routes/config.js @@ -0,0 +1,11 @@ +import { pool } from '../db.js' +import { requireAuth } from '../auth.js' + +export async function configRoutes(app) { + // GET /settings/api/config/:key — any authenticated user (for apps to consume shared config) + app.get('/config/:key', { preHandler: requireAuth }, async (req, reply) => { + const { rows: [row] } = await pool.query('SELECT value, updated_at FROM global_config WHERE key = $1', [req.params.key]) + if (!row) return reply.status(404).send({ error: 'Not found' }) + return { key: req.params.key, value: row.value, updated_at: row.updated_at } + }) +} diff --git a/src/routes/integrations.js b/src/routes/integrations.js new file mode 100644 index 0000000..6cef6c3 --- /dev/null +++ b/src/routes/integrations.js @@ -0,0 +1,146 @@ +import { pool } from '../db.js' +import { encrypt, decrypt } from '../crypto.js' +import { requireAdmin } from '../auth.js' +import { INTEGRATIONS, MASKED } from '../integrations/schema.js' +import { testConnection as newbookTest, fetchSiteList } from '../integrations/newbook.js' +import { testConnection as resosTest } from '../integrations/resos.js' + +function decryptSecrets(row) { + if (!row.secrets) return {} + try { return JSON.parse(decrypt(row.secrets)) } catch { return {} } +} + +function maskRow(row) { + const schema = INTEGRATIONS[row.slug] + const existing = decryptSecrets(row) + const masked = {} + for (const f of schema?.secretFields ?? []) { + masked[f] = existing[f] ? MASKED : null + } + return { slug: row.slug, name: row.name, config: row.config, secrets: masked, enabled: row.enabled, updated_at: row.updated_at } +} + +export async function integrationRoutes(app) { + // GET /settings/api/integrations + app.get('/integrations', { preHandler: requireAdmin }, async () => { + const { rows } = await pool.query('SELECT * FROM integrations ORDER BY slug') + return rows.map(maskRow) + }) + + // GET /settings/api/integrations/:slug + app.get('/integrations/:slug', { preHandler: requireAdmin }, async (req, reply) => { + const { rows: [row] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', [req.params.slug]) + if (!row) return reply.status(404).send({ error: 'Not found' }) + return maskRow(row) + }) + + // PUT /settings/api/integrations/:slug + app.put('/integrations/:slug', { preHandler: requireAdmin }, async (req, reply) => { + const { slug } = req.params + const schema = INTEGRATIONS[slug] + if (!schema) return reply.status(404).send({ error: 'Unknown integration' }) + + const body = req.body || {} + + // Build config (plaintext fields) + const config = {} + for (const f of schema.configFields) { + if (body[f] !== undefined) config[f] = body[f] + } + + // Merge secrets — skip masked sentinel values so we don't overwrite with garbage + const { rows: [existing] } = await pool.query('SELECT secrets FROM integrations WHERE slug = $1', [slug]) + const prev = existing ? decryptSecrets(existing) : {} + const next = { ...prev } + for (const f of schema.secretFields) { + if (body[f] !== undefined && body[f] !== MASKED && body[f] !== '') { + next[f] = body[f] + } + } + const secretsEnc = Object.keys(next).length ? encrypt(JSON.stringify(next)) : null + const enabled = body.enabled !== undefined ? Boolean(body.enabled) : true + + await pool.query(` + INSERT INTO integrations (slug, name, config, secrets, enabled, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (slug) DO UPDATE SET + config = EXCLUDED.config, + secrets = EXCLUDED.secrets, + enabled = EXCLUDED.enabled, + updated_at = NOW() + `, [slug, schema.name, config, secretsEnc, enabled]) + + const { rows: [updated] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', [slug]) + return maskRow(updated) + }) + + // POST /settings/api/integrations/:slug/test + app.post('/integrations/:slug/test', { preHandler: requireAdmin }, async (req, reply) => { + const { rows: [row] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', [req.params.slug]) + if (!row) return reply.status(404).send({ error: 'Not found' }) + const creds = { ...row.config, ...decryptSecrets(row) } + try { + if (req.params.slug === 'newbook') await newbookTest(creds) + else if (req.params.slug === 'resos') await resosTest(creds) + else return reply.status(400).send({ error: `No test available for ${req.params.slug}` }) + return { ok: true } + } catch (e) { + return { ok: false, error: e.message } + } + }) + + // POST /settings/api/integrations/newbook/sync-rooms + app.post('/integrations/newbook/sync-rooms', { preHandler: requireAdmin }, async (req, reply) => { + const { rows: [row] } = await pool.query(`SELECT * FROM integrations WHERE slug = 'newbook'`) + if (!row?.enabled) return reply.status(400).send({ error: 'Newbook integration not enabled' }) + const creds = { ...row.config, ...decryptSecrets(row) } + if (!creds.api_key) return reply.status(400).send({ error: 'Newbook credentials not configured' }) + + let raw + try { raw = await fetchSiteList(creds) } + catch (e) { return reply.status(502).send({ error: e.message }) } + + // Normalise: site_list returns array with category_id, category_name, site_id, site_name + const items = Array.isArray(raw) ? raw : (raw?.data ?? []) + const catMap = {} + const sites = [] + for (const item of items) { + const cid = String(item.category_id) + if (!catMap[cid]) { + catMap[cid] = { id: cid, name: item.category_name, sort_order: Object.keys(catMap).length, colour: null } + } + if (item.site_id) { + sites.push({ id: String(item.site_id), name: item.site_name, category_id: cid }) + } + } + + // Preserve existing sort_order and colour customisations + const { rows: [prev] } = await pool.query(`SELECT value FROM global_config WHERE key = 'newbook.rooms'`) + const prevCats = {} + for (const c of prev?.value?.categories ?? []) prevCats[c.id] = c + + const categories = Object.values(catMap).map(c => ({ + ...c, + sort_order: prevCats[c.id]?.sort_order ?? c.sort_order, + colour: prevCats[c.id]?.colour ?? null, + })).sort((a, b) => a.sort_order - b.sort_order) + + const value = { categories, sites, synced_at: new Date().toISOString() } + await pool.query(` + INSERT INTO global_config (key, value, updated_at) VALUES ('newbook.rooms', $1, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + `, [value]) + + return value + }) + + // PUT /settings/api/integrations/newbook/rooms — save reorder + colour changes + app.put('/integrations/newbook/rooms', { preHandler: requireAdmin }, async (req, reply) => { + const { rows: [row] } = await pool.query(`SELECT value FROM global_config WHERE key = 'newbook.rooms'`) + if (!row) return reply.status(404).send({ error: 'No room data — sync first' }) + const categories = (req.body?.categories ?? row.value.categories).map((c, i) => ({ ...c, sort_order: i })) + const updated = { ...row.value, categories } + await pool.query(`UPDATE global_config SET value = $1, updated_at = NOW() WHERE key = 'newbook.rooms'`, [updated]) + return updated + }) +}