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:
parent
384675b3c6
commit
00ba023a7f
2 changed files with 80 additions and 0 deletions
|
|
@ -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()
|
||||
|
|
|
|||
78
src/routes/dev.js
Normal file
78
src/routes/dev.js
Normal 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
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue