The Yoco secret key and webhook secret now live in AppSetting (AES-256-GCM encrypted), with .env as a fallback. A new Site Settings → Yoco tab shows them masked card-style, registers a webhook with Yoco (storing the secret it returns) or accepts one entered by hand, and lists/deletes the account's webhooks. Generic settings endpoints never read or write these keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
5.7 KiB
JavaScript
150 lines
5.7 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
|
const { encrypt } = require('../utils/encryption');
|
|
const { invalidate: invalidateSettingsCache, warmCache, getSetting } = require('../utils/settingsCache');
|
|
const { logAdminAction } = require('../utils/adminAudit');
|
|
const { getClientIp } = require('../utils/requestUtils');
|
|
const {
|
|
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
|
|
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
|
|
maskSecret, yocoMode, listWebhooks, registerWebhook, deleteWebhook,
|
|
defaultWebhookUrl, webhookUrlProblem,
|
|
} = require('../utils/yoco');
|
|
|
|
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
|
|
|
|
// Write settings (null/'' clears), then refresh the cache so the new values apply immediately.
|
|
async function saveSettings(values) {
|
|
const ops = Object.entries(values).map(([key, value]) => {
|
|
const stored = value ? (key === KEY_WEBHOOK_ID ? value : encrypt(value)) : '';
|
|
return prisma.appSetting.upsert({ where: { key }, update: { value: stored }, create: { key, value: stored } });
|
|
});
|
|
await prisma.$transaction(ops);
|
|
invalidateSettingsCache();
|
|
await warmCache();
|
|
}
|
|
|
|
const audit = (req, action, metadata) => logAdminAction({
|
|
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'AppSetting', metadata, ip: getClientIp(req),
|
|
});
|
|
|
|
// @desc Yoco configuration with secrets masked
|
|
// @route GET /api/yoco/config
|
|
// @access Admin
|
|
const getConfig = async (req, res) => {
|
|
try {
|
|
const [secretKey, webhookSecret, webhookId, source] = await Promise.all([
|
|
getYocoSecretKey(), getYocoWebhookSecret(), getSetting(KEY_WEBHOOK_ID), getYocoConfigSource(),
|
|
]);
|
|
res.json({
|
|
secretKeyMasked: maskSecret(secretKey),
|
|
secretKeySource: source.secretKey,
|
|
mode: yocoMode(secretKey),
|
|
webhookSecretMasked: maskSecret(webhookSecret),
|
|
webhookSecretSource: source.webhookSecret,
|
|
webhookId: webhookId || '',
|
|
webhookUrl: defaultWebhookUrl(req),
|
|
configured: !!(secretKey && webhookSecret),
|
|
});
|
|
} catch (e) {
|
|
fail(res, e);
|
|
}
|
|
};
|
|
|
|
// @desc Save the secret key and/or webhook secret ('' clears)
|
|
// @route PUT /api/yoco/config
|
|
// @access Admin
|
|
const updateConfig = async (req, res) => {
|
|
try {
|
|
const { secretKey, webhookSecret } = req.body || {};
|
|
const updates = {};
|
|
|
|
if (typeof secretKey === 'string') {
|
|
const key = secretKey.trim();
|
|
if (key && !/^sk_(test|live)_\S+$/.test(key)) {
|
|
res.status(400); throw new Error('The secret key should start with sk_test_ or sk_live_.');
|
|
}
|
|
// Check Yoco accepts the key before replacing a working one.
|
|
if (key) await listWebhooks(key);
|
|
updates[KEY_SECRET] = key;
|
|
}
|
|
|
|
if (typeof webhookSecret === 'string') {
|
|
const secret = webhookSecret.trim();
|
|
if (secret && !/^whsec_[A-Za-z0-9+/=]+$/.test(secret)) {
|
|
res.status(400); throw new Error('The webhook secret should start with whsec_.');
|
|
}
|
|
updates[KEY_WEBHOOK] = secret;
|
|
// A typed-in secret isn't tied to a webhook we registered.
|
|
updates[KEY_WEBHOOK_ID] = '';
|
|
}
|
|
|
|
if (!Object.keys(updates).length) { res.status(400); throw new Error('Nothing to save'); }
|
|
|
|
await saveSettings(updates);
|
|
audit(req, 'yoco_config_updated', { changedKeys: Object.keys(updates) });
|
|
res.json({ message: 'Yoco settings saved' });
|
|
} catch (e) {
|
|
fail(res, e);
|
|
}
|
|
};
|
|
|
|
// @desc Webhooks registered on the Yoco account
|
|
// @route GET /api/yoco/webhooks
|
|
// @access Admin
|
|
const getWebhooks = async (req, res) => {
|
|
try {
|
|
const [hooks, activeId] = await Promise.all([listWebhooks(), getSetting(KEY_WEBHOOK_ID)]);
|
|
const here = defaultWebhookUrl(req);
|
|
res.json({
|
|
webhooks: hooks.map(h => ({ ...h, isActive: !!activeId && h.id === activeId, pointsHere: !!here && h.url === here })),
|
|
defaultUrl: here,
|
|
});
|
|
} catch (e) {
|
|
fail(res, e);
|
|
}
|
|
};
|
|
|
|
// @desc Register a webhook at Yoco and store the secret it returns
|
|
// @route POST /api/yoco/webhooks
|
|
// @access Admin
|
|
const createWebhook = async (req, res) => {
|
|
try {
|
|
const name = String(req.body?.name || '').trim() || 'hope-events';
|
|
const url = String(req.body?.url || '').trim() || defaultWebhookUrl(req);
|
|
const problem = webhookUrlProblem(url);
|
|
if (problem) { res.status(400); throw new Error(problem); }
|
|
|
|
const hook = await registerWebhook(name, url);
|
|
if (!hook?.secret) throw new Error('Yoco did not return a webhook secret.');
|
|
|
|
await saveSettings({ [KEY_WEBHOOK]: hook.secret, [KEY_WEBHOOK_ID]: hook.id });
|
|
audit(req, 'yoco_webhook_registered', { webhookId: hook.id, url: hook.url, mode: hook.mode });
|
|
res.status(201).json({
|
|
message: 'Webhook registered and its secret saved',
|
|
webhook: { id: hook.id, name: hook.name, url: hook.url, mode: hook.mode },
|
|
webhookSecretMasked: maskSecret(hook.secret),
|
|
});
|
|
} catch (e) {
|
|
fail(res, e);
|
|
}
|
|
};
|
|
|
|
// @desc Delete a webhook at Yoco (clears the stored secret if it was the active one)
|
|
// @route DELETE /api/yoco/webhooks/:id
|
|
// @access Admin
|
|
const removeWebhook = async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
await deleteWebhook(id);
|
|
const wasActive = (await getSetting(KEY_WEBHOOK_ID)) === id;
|
|
if (wasActive) await saveSettings({ [KEY_WEBHOOK]: '', [KEY_WEBHOOK_ID]: '' });
|
|
audit(req, 'yoco_webhook_deleted', { webhookId: id, clearedSecret: wasActive });
|
|
res.json({ message: wasActive ? 'Webhook deleted and its secret removed' : 'Webhook deleted', clearedSecret: wasActive });
|
|
} catch (e) {
|
|
fail(res, e);
|
|
}
|
|
};
|
|
|
|
module.exports = { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook };
|