-
Notifications
You must be signed in to change notification settings - Fork 1
feat: outbound webhook notifications for important bot events (#130) #219
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
2d2418a
f750a4e
1c854c6
1f619ec
5a6860e
dc5f990
44cd6ba
4561f7c
ca04d36
d207c97
f041302
14bcf2c
4051e0b
1fe44d7
51fd8c4
a715c9b
6188984
496e6e7
1e58a02
82f6775
a73c92a
9b69aba
2b3939a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| /* eslint-disable */ | ||
| 'use strict'; | ||
|
|
||
| /** | ||
| * Migration 005: Webhook notifications delivery log | ||
| * | ||
| * Creates webhook_delivery_log table to store delivery attempts | ||
| * per webhook endpoint. Endpoint configs live in the per-guild config JSON. | ||
| */ | ||
|
|
||
| /** @param {import('node-pg-migrate').MigrationBuilder} pgm */ | ||
| exports.up = (pgm) => { | ||
| pgm.sql(` | ||
| CREATE TABLE IF NOT EXISTS webhook_delivery_log ( | ||
| id SERIAL PRIMARY KEY, | ||
| guild_id TEXT NOT NULL, | ||
| endpoint_id TEXT NOT NULL, | ||
| event_type TEXT NOT NULL, | ||
| payload JSONB NOT NULL, | ||
| status TEXT NOT NULL CHECK (status IN ('success', 'failed', 'pending')), | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| response_code INTEGER, | ||
| response_body TEXT, | ||
| attempt INTEGER NOT NULL DEFAULT 1, | ||
| delivered_at TIMESTAMPTZ DEFAULT NOW() | ||
| ); | ||
|
|
||
| CREATE INDEX IF NOT EXISTS idx_webhook_delivery_log_guild | ||
| ON webhook_delivery_log (guild_id, delivered_at DESC); | ||
|
|
||
| CREATE INDEX IF NOT EXISTS idx_webhook_delivery_log_endpoint | ||
| ON webhook_delivery_log (endpoint_id, delivered_at DESC); | ||
| `); | ||
| }; | ||
|
|
||
| /** @param {import('node-pg-migrate').MigrationBuilder} pgm */ | ||
| exports.down = (pgm) => { | ||
| pgm.sql(` | ||
| DROP INDEX IF EXISTS idx_webhook_delivery_log_endpoint; | ||
| DROP INDEX IF EXISTS idx_webhook_delivery_log_guild; | ||
| DROP TABLE IF EXISTS webhook_delivery_log; | ||
| `); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,311 @@ | ||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||
| * Notification Webhook Routes | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * Endpoints for managing outbound webhook notification endpoints per guild | ||||||||||||||||||||||||||||||||||||||||
| * and viewing the delivery log. Webhook secrets are write-only — they are | ||||||||||||||||||||||||||||||||||||||||
| * never returned in GET responses. | ||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| import { randomUUID } from 'node:crypto'; | ||||||||||||||||||||||||||||||||||||||||
| import { Router } from 'express'; | ||||||||||||||||||||||||||||||||||||||||
| import { error as logError, info } from '../../logger.js'; | ||||||||||||||||||||||||||||||||||||||||
| import { getConfig, setConfigValue } from '../../modules/config.js'; | ||||||||||||||||||||||||||||||||||||||||
| import { getDeliveryLog, testEndpoint, WEBHOOK_EVENTS } from '../../modules/webhookNotifier.js'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const router = Router(); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||
| * Mask the secret field from an endpoint object for safe GET responses. | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * @param {Object} ep - Endpoint config | ||||||||||||||||||||||||||||||||||||||||
| * @returns {Object} Endpoint with secret replaced by a mask indicator | ||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||
| function maskEndpoint(ep) { | ||||||||||||||||||||||||||||||||||||||||
| const { secret: _secret, ...rest } = ep; | ||||||||||||||||||||||||||||||||||||||||
| return { ...rest, hasSecret: Boolean(_secret) }; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||
| * @openapi | ||||||||||||||||||||||||||||||||||||||||
| * /guilds/{id}/notifications/webhooks: | ||||||||||||||||||||||||||||||||||||||||
| * get: | ||||||||||||||||||||||||||||||||||||||||
| * tags: | ||||||||||||||||||||||||||||||||||||||||
| * - Notifications | ||||||||||||||||||||||||||||||||||||||||
| * summary: List webhook endpoints for a guild | ||||||||||||||||||||||||||||||||||||||||
| * description: Returns all configured outbound webhook endpoints. Secrets are never included. | ||||||||||||||||||||||||||||||||||||||||
| * security: | ||||||||||||||||||||||||||||||||||||||||
| * - ApiKeyAuth: [] | ||||||||||||||||||||||||||||||||||||||||
| * - BearerAuth: [] | ||||||||||||||||||||||||||||||||||||||||
| * parameters: | ||||||||||||||||||||||||||||||||||||||||
| * - in: path | ||||||||||||||||||||||||||||||||||||||||
| * name: id | ||||||||||||||||||||||||||||||||||||||||
| * required: true | ||||||||||||||||||||||||||||||||||||||||
| * schema: | ||||||||||||||||||||||||||||||||||||||||
| * type: string | ||||||||||||||||||||||||||||||||||||||||
| * description: Guild ID | ||||||||||||||||||||||||||||||||||||||||
| * responses: | ||||||||||||||||||||||||||||||||||||||||
| * "200": | ||||||||||||||||||||||||||||||||||||||||
| * description: List of webhook endpoints (secrets masked) | ||||||||||||||||||||||||||||||||||||||||
| * "401": | ||||||||||||||||||||||||||||||||||||||||
| * $ref: "#/components/responses/Unauthorized" | ||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||
| router.get('/:guildId/notifications/webhooks', async (req, res) => { | ||||||||||||||||||||||||||||||||||||||||
| const { guildId } = req.params; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| const cfg = getConfig(guildId); | ||||||||||||||||||||||||||||||||||||||||
| const webhooks = Array.isArray(cfg?.notifications?.webhooks) | ||||||||||||||||||||||||||||||||||||||||
| ? cfg.notifications.webhooks.map(maskEndpoint) | ||||||||||||||||||||||||||||||||||||||||
| : []; | ||||||||||||||||||||||||||||||||||||||||
| return res.json(webhooks); | ||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||
| logError('Failed to list webhook endpoints', { guildId, error: err.message }); | ||||||||||||||||||||||||||||||||||||||||
| return res.status(500).json({ error: 'Failed to retrieve webhook endpoints' }); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+83
to
+85
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Align route error handling with shared API error conventions. These handlers return inline 500 responses instead of logging context and re-throwing standardized custom errors. As per coding guidelines Also applies to: 160-163, 211-213, 264-266, 306-308 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||
| * @openapi | ||||||||||||||||||||||||||||||||||||||||
| * /guilds/{id}/notifications/webhooks: | ||||||||||||||||||||||||||||||||||||||||
| * post: | ||||||||||||||||||||||||||||||||||||||||
| * tags: | ||||||||||||||||||||||||||||||||||||||||
| * - Notifications | ||||||||||||||||||||||||||||||||||||||||
| * summary: Add a webhook endpoint | ||||||||||||||||||||||||||||||||||||||||
| * security: | ||||||||||||||||||||||||||||||||||||||||
| * - ApiKeyAuth: [] | ||||||||||||||||||||||||||||||||||||||||
| * - BearerAuth: [] | ||||||||||||||||||||||||||||||||||||||||
| * parameters: | ||||||||||||||||||||||||||||||||||||||||
| * - in: path | ||||||||||||||||||||||||||||||||||||||||
| * name: id | ||||||||||||||||||||||||||||||||||||||||
| * required: true | ||||||||||||||||||||||||||||||||||||||||
| * schema: | ||||||||||||||||||||||||||||||||||||||||
| * type: string | ||||||||||||||||||||||||||||||||||||||||
| * requestBody: | ||||||||||||||||||||||||||||||||||||||||
| * required: true | ||||||||||||||||||||||||||||||||||||||||
| * content: | ||||||||||||||||||||||||||||||||||||||||
| * application/json: | ||||||||||||||||||||||||||||||||||||||||
| * schema: | ||||||||||||||||||||||||||||||||||||||||
| * type: object | ||||||||||||||||||||||||||||||||||||||||
| * required: | ||||||||||||||||||||||||||||||||||||||||
| * - url | ||||||||||||||||||||||||||||||||||||||||
| * - events | ||||||||||||||||||||||||||||||||||||||||
| * properties: | ||||||||||||||||||||||||||||||||||||||||
| * url: | ||||||||||||||||||||||||||||||||||||||||
| * type: string | ||||||||||||||||||||||||||||||||||||||||
| * description: HTTPS delivery URL | ||||||||||||||||||||||||||||||||||||||||
| * events: | ||||||||||||||||||||||||||||||||||||||||
| * type: array | ||||||||||||||||||||||||||||||||||||||||
| * items: | ||||||||||||||||||||||||||||||||||||||||
| * type: string | ||||||||||||||||||||||||||||||||||||||||
| * description: Event types to subscribe to | ||||||||||||||||||||||||||||||||||||||||
| * secret: | ||||||||||||||||||||||||||||||||||||||||
| * type: string | ||||||||||||||||||||||||||||||||||||||||
| * description: Optional HMAC signing secret | ||||||||||||||||||||||||||||||||||||||||
| * enabled: | ||||||||||||||||||||||||||||||||||||||||
| * type: boolean | ||||||||||||||||||||||||||||||||||||||||
| * default: true | ||||||||||||||||||||||||||||||||||||||||
| * responses: | ||||||||||||||||||||||||||||||||||||||||
| * "201": | ||||||||||||||||||||||||||||||||||||||||
| * description: Created endpoint (secret masked) | ||||||||||||||||||||||||||||||||||||||||
| * "400": | ||||||||||||||||||||||||||||||||||||||||
| * $ref: "#/components/responses/BadRequest" | ||||||||||||||||||||||||||||||||||||||||
| * "401": | ||||||||||||||||||||||||||||||||||||||||
| * $ref: "#/components/responses/Unauthorized" | ||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||
| router.post('/:guildId/notifications/webhooks', async (req, res) => { | ||||||||||||||||||||||||||||||||||||||||
| const { guildId } = req.params; | ||||||||||||||||||||||||||||||||||||||||
| const { url, events, secret, enabled = true } = req.body || {}; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (!url || typeof url !== 'string') { | ||||||||||||||||||||||||||||||||||||||||
| return res.status(400).json({ error: 'Missing or invalid "url"' }); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (!/^https?:\/\/.+/.test(url)) { | ||||||||||||||||||||||||||||||||||||||||
BillChirico marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||
| return res.status(400).json({ error: '"url" must be a valid HTTP/HTTPS URL' }); | ||||||||||||||||||||||||||||||||||||||||
BillChirico marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (!Array.isArray(events) || events.length === 0) { | ||||||||||||||||||||||||||||||||||||||||
| return res.status(400).json({ error: '"events" must be a non-empty array' }); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+148
to
+157
|
||||||||||||||||||||||||||||||||||||||||
| // Validate URL against SSRF | |
| try { | |
| validateUrlForSsrfSync(url); | |
| } catch (err) { | |
| return res.status(400).json({ error: err.message }); | |
| } | |
| if (!Array.isArray(events) || events.length === 0) { | |
| return res.status(400).json({ error: '"events" must be a non-empty array' }); | |
| } | |
| // Validate URL against SSRF using synchronous validator | |
| const ssrfResult = validateUrlForSsrfSync(url); | |
| if (!ssrfResult || ssrfResult.valid === false) { | |
| return res.status(400).json({ error: ssrfResult?.error || 'URL is not allowed (potential SSRF target)' }); | |
| } | |
| if (!Array.isArray(events) || events.length === 0) { | |
| return res.status(400).json({ error: '"events" must be a non-empty array' }); | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate secret and enabled types before persisting.
Current coercion allows invalid payloads (e.g., enabled: "false" becomes true, non-string secret can break downstream signing/delivery behavior).
Proposed fix
router.post('/:guildId/notifications/webhooks', async (req, res) => {
const { guildId } = req.params;
const { url, events, secret, enabled = true } = req.body || {};
+
+ if (secret !== undefined && typeof secret !== 'string') {
+ return res.status(400).json({ error: '"secret" must be a string' });
+ }
+
+ if (enabled !== undefined && typeof enabled !== 'boolean') {
+ return res.status(400).json({ error: '"enabled" must be a boolean' });
+ }
if (!url || typeof url !== 'string') {
return res.status(400).json({ error: 'Missing or invalid "url"' });
}
@@
const newEndpoint = {
id: randomUUID(),
url,
events,
- enabled: Boolean(enabled),
+ enabled,
...(secret ? { secret } : {}),
};🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/api/routes/notifications.js` around lines 117 - 153, The payload
currently coerces types which lets invalid values slip through (e.g., enabled:
"false" becomes true and non-string secret values are accepted); update the
validation in src/api/routes/notifications.js by (1) removing the default
boolean coercion on destructuring (do not set enabled = true on const { url,
events, secret, enabled } = req.body || {}), (2) add explicit checks after the
events validation to return 400 if secret is provided and typeof secret !==
'string', and to return 400 if enabled is provided and typeof enabled !==
'boolean', and (3) when constructing newEndpoint (the object with id:
randomUUID(), url, events, enabled: ...), set enabled to enabled === undefined ?
true : enabled (instead of Boolean(enabled)) so only a real boolean or the
implicit default true is persisted.
Uh oh!
There was an error while loading. Please reload this page.