Add Newbook API console backend (admin-only, write guard, in-memory log)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-10-22 17:27:13 +00:00
parent 384675b3c6
commit 00ba023a7f
2 changed files with 80 additions and 0 deletions

View file

@ -4,6 +4,7 @@ import { initDb } from './db.js'
import { integrationRoutes } from './routes/integrations.js' import { integrationRoutes } from './routes/integrations.js'
import { configRoutes } from './routes/config.js' import { configRoutes } from './routes/config.js'
import { mqttClientRoutes } from './routes/mqtt-clients.js' import { mqttClientRoutes } from './routes/mqtt-clients.js'
import { devRoutes } from './routes/dev.js'
const app = Fastify({ logger: true, trustProxy: true }) 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(integrationRoutes, { prefix: '/settings/api' })
await app.register(configRoutes, { prefix: '/settings/api' }) await app.register(configRoutes, { prefix: '/settings/api' })
await app.register(mqttClientRoutes, { prefix: '/settings/api' }) await app.register(mqttClientRoutes, { prefix: '/settings/api' })
await app.register(devRoutes, { prefix: '/settings/api' })
try { try {
await initDb() await initDb()

78
src/routes/dev.js Normal file
View file

@ -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
})
}