From 00ba023a7fc27e303d8b58c5c0c6609338a73660 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 22 Oct 2026 17:27:13 +0000 Subject: [PATCH] Add Newbook API console backend (admin-only, write guard, in-memory log) Co-Authored-By: Claude Sonnet 5.5 --- src/index.js | 2 ++ src/routes/dev.js | 78 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/routes/dev.js diff --git a/src/index.js b/src/index.js index 282ea81..773201a 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import { initDb } from './db.js' import { integrationRoutes } from './routes/integrations.js' import { configRoutes } from './routes/config.js' import { mqttClientRoutes } from './routes/mqtt-clients.js' +import { devRoutes } from './routes/dev.js' const app = Fastify({ logger: true, trustProxy: true }) @@ -17,6 +18,7 @@ app.get('/health', async () => ({ status: 'healthy' })) await app.register(integrationRoutes, { prefix: '/settings/api' }) await app.register(configRoutes, { prefix: '/settings/api' }) await app.register(mqttClientRoutes, { prefix: '/settings/api' }) +await app.register(devRoutes, { prefix: '/settings/api' }) try { await initDb() diff --git a/src/routes/dev.js b/src/routes/dev.js new file mode 100644 index 0000000..5d58a42 --- /dev/null +++ b/src/routes/dev.js @@ -0,0 +1,78 @@ +import { pool } from '../db.js' +import { decrypt } from '../crypto.js' +import { requireAdmin } from '../auth.js' + +const BASE = 'https://api.newbook.cloud/rest' +// Endpoints that only read. Anything else is treated as a write and needs confirm_write. +const READ_ONLY = /(_list|_get|_options)$|^(custom_fields|auth_test|site_list|sites_list|users_list|note_types_list)$/ + +// In-memory only (never persisted): responses contain guest data. +const LOG_MAX = 100 +const log = [] + +async function newbookCreds() { + const { rows: [row] } = await pool.query(`SELECT config, secrets FROM integrations WHERE slug = 'newbook'`) + if (!row) throw new Error('NewBook integration not configured') + let secrets = {} + try { secrets = row.secrets ? JSON.parse(decrypt(row.secrets)) : {} } catch { /* ignore */ } + const c = { ...row.config, ...secrets } + if (!c.username || !c.password || !c.api_key) throw new Error('NewBook credentials incomplete') + return { username: c.username, password: c.password, api_key: c.api_key, region: c.region || 'eu' } +} + +export async function devRoutes(app) { + // GET /settings/api/dev/log + app.get('/dev/log', { preHandler: requireAdmin }, async () => log) + + // POST /settings/api/dev/newbook { endpoint, payload, confirm_write } + app.post('/dev/newbook', { preHandler: requireAdmin }, async (req, reply) => { + const { endpoint, payload, confirm_write } = req.body || {} + if (typeof endpoint !== 'string' || !/^[a-z0-9_]+$/.test(endpoint)) { + return reply.status(400).send({ error: 'endpoint must be a NewBook request name, e.g. contact_documents_list' }) + } + if (payload !== undefined && (typeof payload !== 'object' || payload === null || Array.isArray(payload))) { + return reply.status(400).send({ error: 'payload must be a JSON object' }) + } + const isWrite = !READ_ONLY.test(endpoint) + if (isWrite && confirm_write !== true) { + return reply.status(409).send({ error: `${endpoint} is not a read-only request; confirm_write is required`, write: true }) + } + + let creds + try { creds = await newbookCreds() } catch (e) { return reply.status(503).send({ error: e.message }) } + + // region / api_key always come from the saved integration, never from the caller + const { region: _r, api_key: _k, ...rest } = payload || {} + const sent = { region: creds.region, api_key: creds.api_key, ...rest } + const shown = { ...sent, api_key: '••••••••' } + + const started = Date.now() + const entry = { + at: new Date().toISOString(), user: req.authUser?.email || req.authUser?.name || '?', + endpoint, write: isWrite, request: shown, status: null, ms: null, response: null, error: null, + } + try { + const res = await fetch(`${BASE}/${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Basic ' + Buffer.from(`${creds.username}:${creds.password}`).toString('base64'), + }, + body: JSON.stringify(sent), + signal: AbortSignal.timeout(30000), + }) + const text = await res.text() + entry.status = res.status + try { entry.response = JSON.parse(text) } catch { entry.response = { raw: text.slice(0, 2000) } } + } catch (e) { + entry.error = e.message + } + entry.ms = Date.now() - started + + log.unshift(entry) + if (log.length > LOG_MAX) log.length = LOG_MAX + // summary only: no request/response bodies in the server log + app.log.info({ dev_newbook: endpoint, write: isWrite, status: entry.status, ms: entry.ms, user: entry.user }) + return entry + }) +}