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 <noreply@anthropic.com>
This commit is contained in:
commit
e7b08ccf31
13 changed files with 381 additions and 0 deletions
3
.env.example
Normal file
3
.env.example
Normal file
|
|
@ -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
|
||||
7
Dockerfile
Normal file
7
Dockerfile
Normal file
|
|
@ -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"]
|
||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
|
|
@ -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}
|
||||
11
package.json
Normal file
11
package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
35
src/auth.js
Normal file
35
src/auth.js
Normal file
|
|
@ -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
|
||||
}
|
||||
28
src/crypto.js
Normal file
28
src/crypto.js
Normal file
|
|
@ -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')
|
||||
}
|
||||
33
src/db.js
Normal file
33
src/db.js
Normal file
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
}
|
||||
25
src/index.js
Normal file
25
src/index.js
Normal file
|
|
@ -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)
|
||||
}
|
||||
27
src/integrations/newbook.js
Normal file
27
src/integrations/newbook.js
Normal file
|
|
@ -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)
|
||||
}
|
||||
13
src/integrations/resos.js
Normal file
13
src/integrations/resos.js
Normal file
|
|
@ -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}`)
|
||||
}
|
||||
32
src/integrations/schema.js
Normal file
32
src/integrations/schema.js
Normal file
|
|
@ -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 = '••••••••'
|
||||
11
src/routes/config.js
Normal file
11
src/routes/config.js
Normal file
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
146
src/routes/integrations.js
Normal file
146
src/routes/integrations.js
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue