diff --git a/apps/cli/package.json b/apps/cli/package.json index b4dee98..1b6fca6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/r4ck", - "version": "0.2.0", + "version": "0.3.0", "description": "r4ck.dev from the terminal and for agents: search every VPS, bare metal, GPU and PaaS offer for sale, drill down by spec, price and place, and bridge the same tools into any MCP client.", "type": "module", "main": "src/index.js", diff --git a/apps/cli/src/index.js b/apps/cli/src/index.js index 14bebc2..0729054 100644 --- a/apps/cli/src/index.js +++ b/apps/cli/src/index.js @@ -9,7 +9,7 @@ import { createInterface } from 'node:readline'; * reply verbatim, otherwise a table a person can read. `r4ck mcp` bridges * stdio to the site's MCP endpoint so any client gets every tool. */ -export const VERSION = '0.2.0'; +export const VERSION = '0.3.0'; const DEFAULT_API = process.env.R4CK_API ?? 'https://r4ck.dev'; const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'r4ck'); const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); @@ -65,6 +65,12 @@ export const COMMANDS = [ usage: 'invites [list|create [note]|revoke ]', help: 'Your scarce invites (both sides get pass days) and your link.', }, + { + name: 'webhooks', + usage: + 'webhooks [list|add [--events a,b] [--description d]|update [--url u] [--events a,b] [--on|--off]|rm |test |rotate |secret |deliveries [id]|retry ]', + help: 'Signed webhooks for your account: offers matching a saved search, price drops, pass and key changes.', + }, { name: 'open', usage: 'open ', @@ -488,6 +494,93 @@ export async function run( } throw new Error('usage: r4ck invites [list|create [note]|revoke ]'); } + case 'webhook': + case 'webhooks': { + const [sub = 'list', id, ...more] = rest; + const root = '/api/v1/webhooks'; + const at = (suffix = '') => { + if (!id) throw new Error(`usage: r4ck webhooks ${sub} (r4ck webhooks lists them)`); + return `${root}/${encodeURIComponent(id)}${suffix}`; + }; + const events = () => + typeof flags.events === 'string' + ? flags.events + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + : undefined; + const out = (r, line) => (flags.json ? json(r) : say(line(r))); + switch (sub) { + case 'list': + case 'ls': + return out(await client.get(root), (r) => + [ + ...(r.endpoints.length + ? r.endpoints.map( + (e) => + `${e.id} ${e.enabled ? 'on ' : 'off'} ${e.url} [${e.events.join(',')}]${e.failures ? ` ${e.failures} failures` : ''}`, + ) + : ['no webhooks yet']), + `events: ${(r.event_types ?? []).join(', ')}`, + ].join('\n'), + ); + case 'add': { + if (!id) throw new Error('usage: r4ck webhooks add [--events a,b]'); + const r = await client.post(root, { + url: id, + events: events(), + description: typeof flags.description === 'string' ? flags.description : more.join(' '), + }); + return out( + r, + (x) => `${x.endpoint.id} ${x.endpoint.url}\nsigning secret (shown once): ${x.secret}`, + ); + } + case 'update': { + const patch = {}; + if (typeof flags.url === 'string') patch.url = flags.url; + if (events()) patch.events = events(); + if (typeof flags.description === 'string') patch.description = flags.description; + if (flags.on === true) patch.enabled = true; + if (flags.off === true) patch.enabled = false; + const r = await client.send('PATCH', at(), patch); + return out(r, (x) => `${x.id ?? id} updated`); + } + case 'rm': + case 'remove': + await client.del(at()); + return say('removed'); + case 'test': + return out(await client.post(at('/test')), (r) => + r.ok ? `delivered (HTTP ${r.status})` : `failed: ${r.status ?? r.error}`, + ); + case 'rotate': + return out(await client.post(at('/rotate')), (r) => `new signing secret: ${r.secret}`); + case 'secret': + return out(await client.get(at('/secret')), (r) => r.secret); + case 'deliveries': { + const r = await client.get(id ? at('/deliveries') : `${root}/deliveries`); + return out(r, (x) => + (x.deliveries ?? []).length + ? x.deliveries + .map( + (d) => + `${d.id} ${d.event_type} ${d.status} ${d.attempts}x${d.last_status ? ` HTTP ${d.last_status}` : ''}${d.last_error ? ` ${d.last_error}` : ''}`, + ) + .join('\n') + : 'no deliveries yet', + ); + } + case 'retry': + if (!id) throw new Error('usage: r4ck webhooks retry '); + return out( + await client.post(`${root}/deliveries/${encodeURIComponent(id)}/retry`), + (d) => `${d.id ?? id} queued again`, + ); + default: + throw new Error(`webhooks: unknown subcommand ${sub}`); + } + } case 'open': { if (/^\d+$/.test(rest[0] ?? '')) return say(`${client.base}/servers/${rest[0]}`); return say(`${client.base}/servers?${queryFrom(flags, rest.join(' ') || undefined)}`); diff --git a/apps/web/package.json b/apps/web/package.json index c53698c..d40b95f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@r4ck/web", - "version": "0.1.2", + "version": "0.2.0", "private": true, "type": "module", "main": "src/main.js", @@ -12,6 +12,7 @@ "@profullstack/r4ck": "workspace:*", "@profullstack/redeem-credits": "0.2.0", "@profullstack/throttle": "^0.4.0", + "@profullstack/webhooks": "0.2.1", "@profullstack/x402-gateway": "^0.13.1", "@r4ck/auth": "workspace:*", "@r4ck/config": "workspace:*", diff --git a/apps/web/src/lib/credits.js b/apps/web/src/lib/credits.js index 026a5dc..a493c3c 100644 --- a/apps/web/src/lib/credits.js +++ b/apps/web/src/lib/credits.js @@ -31,6 +31,7 @@ import { config } from '@r4ck/config'; import { sql } from '@r4ck/db'; import * as accounts from '@r4ck/db/accounts'; import { accountPassToken } from './gate.js'; +import { emit } from './webhooks.js'; export const UNIT = 'pass_days'; const DAY_MS = 86_400_000; @@ -42,13 +43,26 @@ export const pgPool = (db = sql) => ({ /** Days of the pass, through the same table a sold pass is written to. */ export async function grantPass({ userId, days, ref, source }) { - return accounts.grantPassDays({ + const row = await accounts.grantPassDays({ userId, days, ref: `grant:${ref}:${userId}`, source, passMinutes: config.x402.passMinutes, }); + // null = this ref was granted before (idempotent), so nothing new to tell. + if (row) + await emit( + userId, + 'pass.granted', + { + days: Number(row.days), + source: row.source, + until: (await accounts.accountPassUntil(userId))?.toISOString() ?? null, + }, + { subject: `account:${userId}` }, + ); + return row; } /** The account's pass: when it runs out and the bearer token for it. */ diff --git a/apps/web/src/lib/http.js b/apps/web/src/lib/http.js index 5db399c..ca7a3a2 100644 --- a/apps/web/src/lib/http.js +++ b/apps/web/src/lib/http.js @@ -30,7 +30,7 @@ export function respond(c, { json, redirectTo, status, notice, error } = {}) { const to = new URL(redirectTo ?? c.req.header('referer') ?? '/', config.siteUrl); if (notice) to.searchParams.set('notice', notice); if (error) to.searchParams.set('error', error); - return c.redirect(to.pathname + to.search, 303); + return c.redirect(to.pathname + to.search + to.hash, 303); } export function requireUser(c) { diff --git a/apps/web/src/lib/llms.js b/apps/web/src/lib/llms.js index 7ec7cc0..be15b85 100644 --- a/apps/web/src/lib/llms.js +++ b/apps/web/src/lib/llms.js @@ -46,6 +46,7 @@ Cheapest priced plan: ${money(s.cheapest_usd)} a month. Last sync: ${s.synced_at - \`GET ${site}/api/v1/providers/:slug\` — one provider with every offer it sells here. - \`GET ${site}/api/v1/deals\` — hosting deals and industry stories. - \`GET ${site}/api/v1/me\`, \`/api/v1/keys\`, \`/api/v1/saved\` — the account, its keys, its saved searches. +- \`${site}/api/v1/webhooks\` — signed webhooks (Standard Webhooks, CloudEvents body) for the account: \`server.listed\` and \`server.price_dropped\` for offers matching a saved search, \`pass.granted\`, \`api_key.created\`, \`api_key.revoked\`. GET/POST, \`/:id\` GET/PATCH/DELETE, \`/:id/test\`, \`/:id/rotate\`, \`/:id/secret\`, \`/deliveries\`. Also \`r4ck webhooks\` and the \`*_webhook\` MCP tools. - \`POST ${site}/mcp\` — MCP, stateless Streamable HTTP. Descriptor at /.well-known/openmcp.json. Kinds: ${KINDS.join(', ')}. Sorts: relevance, price, value (USD per vCPU+GB), ram, vcpu, disk, updated, name. diff --git a/apps/web/src/lib/mcp/tools.js b/apps/web/src/lib/mcp/tools.js index 22386fd..607c545 100644 --- a/apps/web/src/lib/mcp/tools.js +++ b/apps/web/src/lib/mcp/tools.js @@ -2,6 +2,7 @@ import { config } from '@r4ck/config'; import { ALL_PARAMS, KINDS, SORTS } from '@r4ck/core/facets'; import * as credits from '../credits.js'; import * as svc from '../service.js'; +import { EVENT_TYPES, hooks, WebhookError } from '../webhooks.js'; /** * The tools, the single source of truth for tools/list and /docs/mcp. Each @@ -216,8 +217,135 @@ export const TOOLS = [ return r.invite; }, }, + ...webhookTools(), ]; +/** The /api/v1/webhooks API as tools: endpoints on the caller's account. */ +function webhookTools() { + const ID = { type: 'string', description: 'The endpoint id (wh_…), from list_webhooks.' }; + const EVENTS = { + type: 'array', + items: { type: 'string' }, + description: `Event patterns ("*", an exact type or "server.*"). Types: ${EVENT_TYPES.join(', ')}.`, + }; + const asOwner = (fn) => async (args, ctx) => { + const owner = String(needUser(ctx).id); + try { + return await fn(args, owner); + } catch (err) { + if (err instanceof WebhookError) throw toolError(err.message); + throw err; + } + }; + return [ + { + name: 'list_webhooks', + title: 'Your webhooks', + description: + 'The caller’s webhook endpoints (url, event filters, on/off, failures) and the event types r4ck sends: server.listed and server.price_dropped for offers matching a saved search, pass.granted, api_key.created, api_key.revoked.', + key: true, + readOnly: true, + schema: { type: 'object', properties: {} }, + run: asOwner(async (_a, owner) => ({ + endpoints: await hooks().listEndpoints(owner), + event_types: EVENT_TYPES, + })), + }, + { + name: 'create_webhook', + title: 'Add a webhook', + description: + 'Register an https URL to receive the caller’s events as signed POSTs (CloudEvents body, Standard Webhooks headers). Returns the endpoint and its signing secret (whsec_…), shown once.', + key: true, + readOnly: false, + schema: { + type: 'object', + properties: { url: { type: 'string' }, events: EVENTS, description: { type: 'string' } }, + required: ['url'], + }, + run: asOwner((a, owner) => + hooks().createEndpoint(owner, { + url: String(a.url ?? ''), + events: a.events, + description: String(a.description ?? ''), + }), + ), + }, + { + name: 'update_webhook', + title: 'Change a webhook', + description: + 'Change an endpoint’s url, events or description, or switch it on/off (switching on clears its failure count).', + key: true, + readOnly: false, + schema: { + type: 'object', + properties: { + id: ID, + url: { type: 'string' }, + events: EVENTS, + description: { type: 'string' }, + enabled: { type: 'boolean' }, + }, + required: ['id'], + }, + run: asOwner((a, owner) => { + const { id, ...patch } = a; + return hooks().updateEndpoint(owner, String(id ?? ''), patch); + }), + }, + { + name: 'delete_webhook', + title: 'Remove a webhook', + description: 'Delete an endpoint and its delivery log.', + key: true, + readOnly: false, + schema: { type: 'object', properties: { id: ID }, required: ['id'] }, + run: asOwner((a, owner) => hooks().deleteEndpoint(owner, String(a.id ?? ''))), + }, + { + name: 'test_webhook', + title: 'Send a test delivery', + description: + 'Send a signed webhook.test event to the endpoint now and report the HTTP status.', + key: true, + readOnly: false, + schema: { type: 'object', properties: { id: ID }, required: ['id'] }, + run: asOwner((a, owner) => hooks().test(owner, String(a.id ?? ''))), + }, + { + name: 'rotate_webhook_secret', + title: 'Replace a webhook’s signing secret', + description: 'Mint a new signing secret and return it; the old one stops working at once.', + key: true, + readOnly: false, + schema: { type: 'object', properties: { id: ID }, required: ['id'] }, + run: asOwner((a, owner) => hooks().rotateSecret(owner, String(a.id ?? ''))), + }, + { + name: 'webhook_deliveries', + title: 'Webhook delivery log', + description: + 'Recent deliveries (status, attempts, last HTTP status or error, payload), for one endpoint or all.', + key: true, + readOnly: true, + schema: { + type: 'object', + properties: { + id: { ...ID, description: 'Only this endpoint; omit for all.' }, + limit: { type: 'integer', minimum: 1, maximum: 200 }, + }, + }, + run: asOwner(async (a, owner) => ({ + deliveries: await hooks().deliveries(owner, { + endpointId: a.id ? String(a.id) : undefined, + limit: Math.min(Number(a.limit) || 50, 200), + }), + })), + }, + ]; +} + export const TOOLS_BY_NAME = new Map(TOOLS.map((t) => [t.name, t])); export const OPEN_TOOLS = TOOLS.filter((t) => !t.key).map((t) => t.name); @@ -235,9 +363,9 @@ export const describe = (t) => ({ description: t.description, inputSchema: t.schema, annotations: { - readOnlyHint: !['save_search', 'delete_saved_search', 'redeem_code', 'create_invite'].includes( - t.name, - ), + readOnlyHint: + t.readOnly ?? + !['save_search', 'delete_saved_search', 'redeem_code', 'create_invite'].includes(t.name), openWorldHint: false, }, }); diff --git a/apps/web/src/lib/webhooks.js b/apps/web/src/lib/webhooks.js new file mode 100644 index 0000000..90e4b0a --- /dev/null +++ b/apps/web/src/lib/webhooks.js @@ -0,0 +1,155 @@ +import { + postgresStore, + verifySignature, + WebhookError, + Webhooks, + webhooksApi, +} from '@profullstack/webhooks'; +import { config } from '@r4ck/config'; +import { jsonb, sql } from '@r4ck/db'; +import { search } from '@r4ck/db/search'; +import { agentViews, serverOut } from './serialize.js'; +import { resolveFilters } from './service.js'; + +/** + * Customer webhooks on @profullstack/webhooks: endpoints, signing keys, event + * filters and a durable outbox in this site's Postgres (migration 0004). The + * owner of an endpoint is the account (users.id); every event is about that + * account: its saved searches, its pass, its keys. + */ +export const EVENT_TYPES = [ + 'server.listed', + 'server.price_dropped', + 'pass.granted', + 'api_key.created', + 'api_key.revoked', +]; + +export const EVENT_HELP = { + 'server.listed': 'A new offer matches one of your saved searches.', + 'server.price_dropped': + 'An offer matching one of your saved searches got cheaper (billed-in price).', + 'pass.granted': 'Days of the pass were added to your account (credit code or invite).', + 'api_key.created': 'An API key was made on your account.', + 'api_key.revoked': 'An API key on your account was revoked.', +}; + +/** + * Bun.SQL in the `query(text, params) -> { rows }` shape postgresStore takes. + * Bun's client has no pg pool; `unsafe` takes $1-style text and a params array. + */ +export const pgQuery = (db) => ({ + query: async (text, params = []) => ({ rows: await db.unsafe(text, params) }), +}); + +let current = null; + +/** One hub per process. `start()` it once at boot (main.js), never in tests. */ +export function hooks() { + current ??= makeHooks(); + return current; +} + +export function makeHooks({ db = sql, ...options } = {}) { + return new Webhooks({ + store: postgresStore(pgQuery(db)), + source: config.siteUrl, + eventTypes: EVENT_TYPES, + ...options, + }); +} + +/** For tests: swap the process hub (a fetch stub, an open guard). */ +export function setHooks(h) { + current = h; +} + +/** The /api/v1/webhooks fetch handler for one signed-in account. */ +export const webhooksHandler = (ownerId) => + webhooksApi(hooks(), { basePath: '/api/v1/webhooks', owner: () => ownerId }); + +/** Fire and forget: an event never breaks the request that caused it. */ +export async function emit(owner, type, data, options) { + try { + return await hooks().emit(String(owner), type, data, options); + } catch (err) { + console.error(`[webhooks] ${type} for ${owner} not queued: ${err?.message ?? err}`); + return null; + } +} + +/** What an api_key.* event says about a key: never the key itself. */ +export const keyEvent = (created, name) => ({ + id: created.id, + name: String(name ?? 'default').slice(0, 60), + prefix: created.prefix, +}); + +const MAX_SERVERS = 50; + +/** + * A mirror tick listed and cut prices on some offers: tell each account whose + * saved search matches, one event per saved search and type, with the offers. + * Only accounts with an enabled endpoint are looked at. + */ +export async function notifyWatchers( + { listed = [], dropped = [] } = {}, + { h = hooks(), db = sql } = {}, +) { + const ids = [...new Set([...listed, ...dropped.map((d) => d.id)].map(Number))]; + if (!ids.length) return { emitted: 0 }; + const watched = await db` + select s.id, s.user_id, s.name, s.query from saved_searches s + where s.user_id::text in (select owner from webhook_endpoints where enabled = 1) + `; + const listedSet = new Set(listed.map(Number)); + const drops = new Map(dropped.map((d) => [Number(d.id), d])); + let emitted = 0; + for (const w of watched) { + const query = jsonb(w.query); + const { filters } = resolveFilters(query); + const r = await search({ ...filters, ids }, { limit: 500, offset: 0 }); + if (!r.servers.length) continue; + const saved = { id: w.id, name: w.name, url: agentViews(query).html ?? null }; + const fresh = r.servers.filter((s) => listedSet.has(Number(s.id))); + const cheaper = r.servers.filter((s) => drops.has(Number(s.id))); + if (fresh.length) { + await h.emit( + String(w.user_id), + 'server.listed', + { + saved_search: saved, + total: fresh.length, + servers: fresh.slice(0, MAX_SERVERS).map(serverOut), + }, + { subject: `saved_search:${w.id}` }, + ); + emitted++; + } + if (cheaper.length) { + await h.emit( + String(w.user_id), + 'server.price_dropped', + { + saved_search: saved, + total: cheaper.length, + servers: cheaper.slice(0, MAX_SERVERS).map((s) => ({ + ...serverOut(s), + price_before: drops.get(Number(s.id)).from, + })), + }, + { subject: `saved_search:${w.id}` }, + ); + emitted++; + } + } + return { emitted }; +} + +/** Remove an account's endpoints and log (account deletion). */ +export async function forgetOwner(owner, db = sql) { + await db`delete from webhook_deliveries where owner = ${String(owner)}`; + await db`delete from webhook_endpoints where owner = ${String(owner)}`; +} + +export { verifySignature, WebhookError }; diff --git a/apps/web/src/main.js b/apps/web/src/main.js index 34cc5b4..68bb6e3 100644 --- a/apps/web/src/main.js +++ b/apps/web/src/main.js @@ -3,6 +3,7 @@ import { close as closeDb, healthcheck } from '@r4ck/db'; import { migrate } from '@r4ck/db/migrate'; import { buildDaemon } from '@r4ck/sync/daemon'; import { app } from './app.js'; +import { hooks, notifyWatchers } from './lib/webhooks.js'; /** One process, one container. ROLES decides what runs: web, worker, or both. */ try { @@ -15,7 +16,11 @@ if (!(await healthcheck())) throw new Error('database healthcheck failed at boot // The worker role is the updater daemon (@profullstack/scraper-daemon), see packages/sync/src/daemon.js. let daemon = null; -if (config.roles.includes('worker') && config.nichedb.syncOnBoot) daemon = buildDaemon().start(); +if (config.roles.includes('worker') && config.nichedb.syncOnBoot) + daemon = buildDaemon({ onChanges: (changes) => notifyWatchers(changes) }).start(); + +// Customer webhooks: drain the outbox (SKIP LOCKED, so any number of processes may). +hooks().start(); let server = null; if (config.roles.includes('web')) { @@ -28,6 +33,7 @@ if (config.roles.includes('web')) { async function shutdown(signal) { console.log(`[boot] ${signal}, draining`); server?.stop(true); + hooks().stop(); await daemon?.stop({ graceMs: 5_000 }); await closeDb().catch(() => {}); process.exit(0); diff --git a/apps/web/src/routes/api.js b/apps/web/src/routes/api.js index 126e71d..455fad4 100644 --- a/apps/web/src/routes/api.js +++ b/apps/web/src/routes/api.js @@ -8,6 +8,7 @@ import { meterApi, pricing } from '../lib/gate.js'; import { Denied, requireUser } from '../lib/http.js'; import { profiles, profileUser } from '../lib/profile.js'; import * as svc from '../lib/service.js'; +import { emit, keyEvent, webhooksHandler } from '../lib/webhooks.js'; const CORS = { 'access-control-allow-origin': '*', @@ -73,6 +74,7 @@ export function registerApi(app) { '/api/v1/pass', '/api/v1/credits/redeem', '/api/v1/invites', + '/api/v1/webhooks', ], kinds: KINDS, sorts: SORTS, @@ -200,6 +202,9 @@ export function registerApi(app) { userId: user.id, name: String(body.name ?? 'default').slice(0, 60), }); + await emit(user.id, 'api_key.created', keyEvent(created, body.name), { + subject: `account:${user.id}`, + }); return c.json( { key: created.key, @@ -213,6 +218,12 @@ export function registerApi(app) { app.delete('/api/v1/keys/:id', async (c) => { const user = requireUser(c); await auth.revokeApiKey({ userId: user.id, id: c.req.param('id') }); + await emit( + user.id, + 'api_key.revoked', + { id: c.req.param('id') }, + { subject: `account:${user.id}` }, + ); return c.json({ ok: true }); }); app.get('/api/v1/saved', async (c) => c.json(await svc.listSaved(requireUser(c)))); @@ -227,6 +238,17 @@ export function registerApi(app) { app.delete('/api/v1/saved/:id', async (c) => c.json(await svc.deleteSaved(requireUser(c), c.req.param('id'))), ); + + /* ------------------------------------------------------------ webhooks -- */ + // @profullstack/webhooks' own API, for the account behind the key or session. + const webhooks = async (c) => { + const user = requireUser(c); + return ( + (await webhooksHandler(String(user.id))(c.req.raw)) ?? c.json({ error: 'Not found' }, 404) + ); + }; + app.all('/api/v1/webhooks', webhooks); + app.all('/api/v1/webhooks/*', webhooks); } const keyOut = (k) => ({ diff --git a/apps/web/src/routes/pages.js b/apps/web/src/routes/pages.js index 45df8c0..3503419 100644 --- a/apps/web/src/routes/pages.js +++ b/apps/web/src/routes/pages.js @@ -7,6 +7,15 @@ import { accessFor } from '../lib/gate.js'; import { Denied, notice, render, requireUser, respond, wantsJson } from '../lib/http.js'; import { profiles } from '../lib/profile.js'; import * as svc from '../lib/service.js'; +import { + EVENT_HELP, + EVENT_TYPES, + emit, + forgetOwner, + hooks, + keyEvent, + WebhookError, +} from '../lib/webhooks.js'; import { Settings } from '../views/account.jsx'; import { Deals } from '../views/deals.jsx'; import { About, ApiDocs, CliDocs, Pricing } from '../views/docs.jsx'; @@ -21,13 +30,15 @@ import { Compare, Server, Servers } from '../views/servers.jsx'; export async function settingsPage(c, extra = {}) { const user = requireUser(c); const admin = user.role === 'admin'; - const [keys, passkeys, saved, contact, pass, codes] = await Promise.all([ + const [keys, passkeys, saved, contact, pass, codes, endpoints, deliveries] = await Promise.all([ auth.listApiKeys(user.id), accounts.listPasskeys(user.id), svc.listSaved(user), profiles.get(user.id), credits.passFor(user), admin ? credits.services().credits.list() : Promise.resolve(null), + hooks().listEndpoints(String(user.id)), + hooks().deliveries(String(user.id), { limit: 10 }), ]); c.header('cache-control', 'private, no-store'); return c.html( @@ -42,6 +53,13 @@ export async function settingsPage(c, extra = {}) { pass={pass} codes={codes ? codes.map((x) => credits.codeOut(x)) : null} freshCode={extra.freshCode ?? null} + webhooks={{ + endpoints, + deliveries, + eventTypes: EVENT_TYPES, + help: EVENT_HELP, + fresh: extra.freshWebhook ?? null, + }} {...notice(c)} />, ), @@ -162,6 +180,74 @@ export function registerPages(app) { notice: 'Contact details saved.', }); }); + // Webhooks from the settings page, without script. A secret is shown once, on + // the page that answers the form, never put in a URL. + const hookForm = (fn) => async (c) => { + const user = requireUser(c); + try { + return await fn(c, String(user.id)); + } catch (err) { + if (!(err instanceof WebhookError)) throw err; + return respond(c, { + redirectTo: '/settings#webhooks', + error: err.message, + status: err.status, + }); + } + }; + app.post( + '/settings/webhooks', + hookForm(async (c, owner) => { + const body = await c.req.parseBody({ all: true }); + const picked = [body.events ?? []].flat().map(String).filter(Boolean); + const r = await hooks().createEndpoint(owner, { + url: String(body.url ?? '').trim(), + events: picked.length ? picked : ['*'], + description: String(body.description ?? '').slice(0, 200), + }); + return settingsPage(c, { freshWebhook: { url: r.endpoint.url, secret: r.secret } }); + }), + ); + app.post( + '/settings/webhooks/:id/test', + hookForm(async (c, owner) => { + const r = await hooks().test(owner, c.req.param('id')); + return respond(c, { + json: r, + status: 200, + redirectTo: '/settings#webhooks', + ...(r.ok + ? { notice: `Test delivered (HTTP ${r.status}).` } + : { error: `Test failed: ${r.status ? `HTTP ${r.status}` : (r.error ?? 'no answer')}.` }), + }); + }), + ); + app.post( + '/settings/webhooks/:id/rotate', + hookForm(async (c, owner) => { + const e = await hooks().getEndpoint(owner, c.req.param('id')); + const r = await hooks().rotateSecret(owner, e.id); + return settingsPage(c, { freshWebhook: { url: e.url, secret: r.secret, rotated: true } }); + }), + ); + app.post( + '/settings/webhooks/:id/toggle', + hookForm(async (c, owner) => { + const e = await hooks().getEndpoint(owner, c.req.param('id')); + await hooks().updateEndpoint(owner, e.id, { enabled: !e.enabled }); + return respond(c, { + redirectTo: '/settings#webhooks', + notice: e.enabled ? 'Webhook paused.' : 'Webhook switched on.', + }); + }), + ); + app.post( + '/settings/webhooks/:id/delete', + hookForm(async (c, owner) => { + await hooks().deleteEndpoint(owner, c.req.param('id')); + return respond(c, { redirectTo: '/settings#webhooks', notice: 'Webhook removed.' }); + }), + ); app.post('/api/keys', async (c) => { const user = requireUser(c); const body = await c.req.parseBody(); @@ -169,12 +255,21 @@ export function registerPages(app) { userId: user.id, name: String(body.name ?? 'default').slice(0, 60), }); + await emit(user.id, 'api_key.created', keyEvent(created, body.name), { + subject: `account:${user.id}`, + }); if (wantsJson(c)) return c.json(created, 201); return c.redirect(`/settings?key=${encodeURIComponent(created.key)}`, 303); }); app.post('/api/keys/:id/revoke', async (c) => { const user = requireUser(c); await auth.revokeApiKey({ userId: user.id, id: c.req.param('id') }); + await emit( + user.id, + 'api_key.revoked', + { id: c.req.param('id') }, + { subject: `account:${user.id}` }, + ); return respond(c, { redirectTo: '/settings', notice: 'Key revoked.' }); }); app.post('/api/saved', async (c) => { @@ -198,6 +293,7 @@ export function registerPages(app) { const user = requireUser(c); const { sql } = await import('@r4ck/db'); await sql`delete from users where id = ${user.id}`; + await forgetOwner(user.id); await profiles.remove(user.id); c.header('set-cookie', auth.sessionCookie('', { clear: true })); return respond(c, { redirectTo: '/', notice: 'Account deleted.' }); diff --git a/apps/web/src/views/account.jsx b/apps/web/src/views/account.jsx index 15242a1..f1187c4 100644 --- a/apps/web/src/views/account.jsx +++ b/apps/web/src/views/account.jsx @@ -4,6 +4,7 @@ import { raw } from 'hono/html'; import { CodesPanel, PassPanel } from './credits.jsx'; import { ago } from './home.jsx'; import { Layout } from './Layout.jsx'; +import { WebhooksPanel } from './webhooks.jsx'; /* * The rest of the shared profile (@profullstack/profile), asked for on the signup @@ -119,6 +120,7 @@ export function Settings({ pass = null, codes = null, freshCode = null, + webhooks = null, notice, error, }) { @@ -274,6 +276,7 @@ export function Settings({

)} +

Agents

diff --git a/apps/web/src/views/docs.jsx b/apps/web/src/views/docs.jsx
index 1ad1729..2b45e0f 100644
--- a/apps/web/src/views/docs.jsx
+++ b/apps/web/src/views/docs.jsx
@@ -188,6 +188,10 @@ export function ApiDocs({ user }) {
                 'GET/POST /api/v1/saved · DELETE /api/v1/saved/:id',
                 'Saved searches. POST {name, query}.',
               ],
+              [
+                'GET/POST /api/v1/webhooks · GET/PATCH/DELETE /api/v1/webhooks/:id · POST /:id/test · POST /:id/rotate · GET /deliveries',
+                'Signed webhooks for your account (Standard Webhooks headers, CloudEvents body): server.listed and server.price_dropped for offers matching a saved search, pass.granted, api_key.created, api_key.revoked. POST {url, events?, description?} returns the secret once.',
+              ],
               ['POST /mcp', 'MCP, stateless Streamable HTTP. See /docs/mcp.'],
             ].map(([a, b]) => (
               
diff --git a/apps/web/src/views/webhooks.jsx b/apps/web/src/views/webhooks.jsx
new file mode 100644
index 0000000..c75a6f4
--- /dev/null
+++ b/apps/web/src/views/webhooks.jsx
@@ -0,0 +1,137 @@
+import { config } from '@r4ck/config';
+import { ago } from './home.jsx';
+
+/**
+ * Settings: webhook endpoints for this account (@profullstack/webhooks). The
+ * same things as /api/v1/webhooks, `r4ck webhooks` and the *_webhook MCP tools.
+ */
+export function WebhooksPanel({ webhooks }) {
+  if (!webhooks) return null;
+  const { endpoints, deliveries, eventTypes, help, fresh } = webhooks;
+  return (
+    
+

Webhooks

+

+ Signed POSTs to your URL when something happens on your account: a CloudEvents JSON body + with webhook-id, webhook-timestamp and{' '} + webhook-signature headers (Standard Webhooks; verify with any Standard Webhooks + library). Retried for about three days. Saved searches are what you watch. +

+ {fresh ? ( +
+

{fresh.rotated ? 'New signing secret' : 'Webhook added'}

+

+ Signing secret for {fresh.url}. Shown once here; also at{' '} + GET /api/v1/webhooks/:id/secret. +

+
+            {fresh.secret}
+          
+

+ +

+
+ ) : null} + {endpoints.length ? ( +
    + {endpoints.map((e) => ( +
  • + + {e.url} {e.description ? {e.description} : null} + + + {e.events.join(', ')} ·{' '} + {e.enabled ? 'on' : `off${e.disabled_reason ? ` (${e.disabled_reason})` : ''}`} + {e.failures ? ` · ${e.failures} failures in a row` : ''} · added {ago(e.created_at)} + + +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
  • + ))} +
+ ) : ( +

No webhooks yet.

+ )} +
+ + +
+ Events (none ticked = all) + {eventTypes.map((t) => ( + + ))} +
+ +
+ {deliveries.length ? ( + <> +

Recent deliveries

+
    + {deliveries.map((d) => ( +
  • + + {d.event_type} {d.status} + + + {d.attempts} attempt{d.attempts === 1 ? '' : 's'} + {d.last_status ? ` · HTTP ${d.last_status}` : ''} + {d.last_error ? ` · ${d.last_error}` : ''} · {ago(d.created_at)} + +
  • + ))} +
+ + ) : null} +
+        {`r4ck webhooks add https://example.com/hooks/r4ck --events server.price_dropped\ncurl -H "Authorization: Bearer r4k_…" ${config.siteUrl}/api/v1/webhooks`}
+      
+
+ ); +} diff --git a/bun.lock b/bun.lock index a3a3177..70cf02d 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,7 @@ "@profullstack/r4ck": "workspace:*", "@profullstack/redeem-credits": "0.2.0", "@profullstack/throttle": "^0.4.0", + "@profullstack/webhooks": "0.2.1", "@profullstack/x402-gateway": "^0.13.1", "@r4ck/auth": "workspace:*", "@r4ck/config": "workspace:*", @@ -218,6 +219,8 @@ "@profullstack/throttle": ["@profullstack/throttle@0.4.0", "", { "dependencies": { "@profullstack/x402-gateway": "^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0" } }, "sha512-Lj+XHt51OxyaJ2FqosZDyr9Z5RFB8z+ARixpoUfrDu3YlvFlct5GbbZM/C0qLZkUgzeuFSDvtYt9f0NNfBkFqA=="], + "@profullstack/webhooks": ["@profullstack/webhooks@0.2.1", "", {}, "sha512-QHeSkpu4O44oJ620qhQqxqFlD3u/OhupmXRnBHw4MGKuDTa0R+Y/727nnF7psSHxxSfnbWfNuVl15z3IXuaZiw=="], + "@profullstack/x402-gateway": ["@profullstack/x402-gateway@0.13.1", "", { "dependencies": { "@profullstack/keys": "^0.1.0" } }, "sha512-V7liSO0tShZRlp8dancuDuUSF/rqrr5YF3bzLoZy9wn6ixf1RIRPYTepe72s36JOdSVzi3wfuhaZFm9y0RpV4w=="], "@r4ck/auth": ["@r4ck/auth@workspace:packages/auth"], diff --git a/package.json b/package.json index 1f71560..d7315e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "r4ck", - "version": "0.1.2", + "version": "0.2.0", "private": true, "type": "module", "description": "r4ck.dev: the server search engine built for agents. Every VPS, bare metal, GPU and PaaS offer for sale, with drill-down filters, keys, x402 and an API, CLI and MCP that speak the same query.", diff --git a/packages/db/migrations/0004_webhooks.sql b/packages/db/migrations/0004_webhooks.sql new file mode 100644 index 0000000..5f858e6 --- /dev/null +++ b/packages/db/migrations/0004_webhooks.sql @@ -0,0 +1,37 @@ +-- Customer webhooks (@profullstack/webhooks 0.2.1): endpoints and the durable delivery outbox. +-- The package's own schemaSql('pg'). Owners are users.id as text. +CREATE TABLE IF NOT EXISTS webhook_endpoints ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + url TEXT NOT NULL, + secret TEXT NOT NULL, + events TEXT NOT NULL DEFAULT '["*"]', + description TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + failures INTEGER NOT NULL DEFAULT 0, + disabled_reason TEXT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS webhook_endpoints_owner ON webhook_endpoints(owner); + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id TEXT PRIMARY KEY, + endpoint_id TEXT NOT NULL, + owner TEXT NOT NULL, + event_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + next_at BIGINT NOT NULL, + last_status INTEGER, + last_error TEXT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS webhook_deliveries_due ON webhook_deliveries(status, next_at); + +CREATE INDEX IF NOT EXISTS webhook_deliveries_endpoint ON webhook_deliveries(endpoint_id, created_at); diff --git a/packages/db/src/catalog.js b/packages/db/src/catalog.js index aba0323..5083d7d 100644 --- a/packages/db/src/catalog.js +++ b/packages/db/src/catalog.js @@ -82,7 +82,9 @@ export async function upsertServer(s) { updated_at = excluded.updated_at, first_seen_at = coalesce(servers.first_seen_at, excluded.first_seen_at), synced_at = now() returning id, (xmax = 0) as created, - (select monthly_usd from price_points pp where pp.server_id = servers.id order by seen_at desc limit 1) as last_monthly + (select monthly_usd from price_points pp where pp.server_id = servers.id order by seen_at desc limit 1) as last_monthly, + (select price from price_points pp where pp.server_id = servers.id order by seen_at desc limit 1) as last_price, + (select currency from price_points pp where pp.server_id = servers.id order by seen_at desc limit 1) as last_currency `; const changed = row.created || Number(row.last_monthly ?? -1) !== Number(s.monthly_usd ?? -1); if (changed) { @@ -92,7 +94,25 @@ export async function upsertServer(s) { on conflict do nothing `; } - return { id: row.id, created: row.created, priceChanged: !row.created && changed }; + // The billed-in price before this sync, so a watcher hears about a real cut, not an FX wobble. + const lastPrice = row.last_price ?? null; + const lastCurrency = row.last_currency ?? null; + // Only when a new price point was written, or the same drop would be heard on every sync. + const priceDropped = + changed && + !row.created && + lastPrice !== null && + s.price !== null && + s.price !== undefined && + lastCurrency === s.currency && + Number(s.price) < Number(lastPrice); + return { + id: row.id, + created: row.created, + priceChanged: !row.created && changed, + priceDropped, + previous: priceDropped ? { price: Number(lastPrice), currency: lastCurrency } : null, + }; } export async function upsertDeal(d) { diff --git a/packages/sync/src/daemon.js b/packages/sync/src/daemon.js index 15b29f9..8703f6d 100644 --- a/packages/sync/src/daemon.js +++ b/packages/sync/src/daemon.js @@ -26,7 +26,8 @@ export function looksLikeOpenServer(doc) { return Boolean(doc.openserver || doc.$schema?.includes?.('openserver') || lists.length); } -export function buildDaemon({ log = console.log } = {}) { +/** `onChanges({ listed, dropped })` hears what each mirror tick listed and cut (the web app's watchers). */ +export function buildDaemon({ log = console.log, onChanges = null } = {}) { const daemon = createDaemon({ name: config.siteName, // BullMQ on the site's own Redis (the compose `redis` service): queue, run records and task @@ -61,6 +62,7 @@ export function buildDaemon({ log = console.log } = {}) { log: ctx.log, budgetMs: config.nichedb.budgetMs, walkEveryMs: config.nichedb.walkHours * 3600_000, + onChanges, }); }, }); diff --git a/packages/sync/src/index.js b/packages/sync/src/index.js index d7b658c..644bba5 100644 --- a/packages/sync/src/index.js +++ b/packages/sync/src/index.js @@ -64,7 +64,7 @@ export async function refreshRates({ log = console.log } = {}) { */ export async function applyItems( items, - { rates, log = () => {}, counts = freshCounts(), watermark = null } = {}, + { rates, log = () => {}, counts = freshCounts(), watermark = null, changes = null } = {}, ) { const pending = []; for (const item of items) { @@ -87,6 +87,13 @@ export async function applyItems( counts.servers++; if (r.created) counts.created++; if (r.priceChanged) counts.priceChanges++; + if (changes && r.created) changes.listed.push(r.id); + if (changes && r.priceDropped) + changes.dropped.push({ + id: r.id, + from: r.previous, + to: { price: s.price, currency: s.currency }, + }); } else if (item.kind === 'deal' || item.kind === 'story') { await cat.upsertDeal({ ...itemToDeal(item), @@ -157,7 +164,21 @@ export async function syncTick({ budgetMs = 8 * 60_000, walkEveryMs = 24 * 3600_000, now = Date.now, + onChanges = null, } = {}) { + // What this tick listed and cut, for the watchers (webhooks on saved searches). + const changes = { listed: [], dropped: [] }; + try { + return await tick({ log, client, budgetMs, walkEveryMs, now, changes }); + } finally { + if (onChanges && (changes.listed.length || changes.dropped.length)) + await Promise.resolve(onChanges(changes)).catch((err) => + log(`[sync] change listener failed: ${err?.message ?? err}`), + ); + } +} + +async function tick({ log, client, budgetMs, walkEveryMs, now, changes }) { const started = now(); const state = (await cat.getSyncState('nichedb')) ?? {}; const rates = await currentRates(); @@ -175,7 +196,7 @@ export async function syncTick({ try { const recent = await client.recent(); counts.pages++; - ({ counts, watermark } = await applyItems(recent, { rates, counts, watermark })); + ({ counts, watermark } = await applyItems(recent, { rates, counts, watermark, changes })); log( `[sync] recent: ${recent.length} rows, ${counts.created} new, ${counts.priceChanges} price changes`, ); @@ -206,7 +227,7 @@ export async function syncTick({ exhausted = true; break; } - ({ counts, watermark } = await applyItems(page, { rates, counts, watermark })); + ({ counts, watermark } = await applyItems(page, { rates, counts, watermark, changes })); seen += page.length; before = Math.min(...page.map((i) => Number(i.id))); log(`[sync] walk: ${seen} rows so far (before ${before})`); diff --git a/test/cli.test.js b/test/cli.test.js index b556f83..384fe38 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -125,3 +125,75 @@ describe('cli run', () => { expect(auth).toBe('Bearer r4k_abc'); }); }); + +describe('cli webhooks', () => { + test('add posts url + events, list and update speak /api/v1/webhooks', async () => { + const calls = []; + const fetchImpl = async (url, init) => { + const u = new URL(url); + calls.push({ + method: init.method, + path: u.pathname, + body: init.body ? JSON.parse(init.body) : null, + }); + if (init.method === 'POST' && u.pathname === '/api/v1/webhooks') + return new Response( + JSON.stringify({ + endpoint: { id: 'wh_1', url: 'https://h.example/x' }, + secret: 'whsec_s', + }), + { status: 201 }, + ); + if (init.method === 'GET' && u.pathname === '/api/v1/webhooks') + return new Response( + JSON.stringify({ + endpoints: [ + { id: 'wh_1', enabled: true, url: 'https://h.example/x', events: ['*'], failures: 0 }, + ], + event_types: ['server.listed'], + }), + ); + return new Response(JSON.stringify({ id: 'wh_1' })); + }; + let text = ''; + const io = { + stdout: { write: (s) => (text += s) }, + stderr: { write: (s) => (text += s) }, + fetchImpl, + }; + await run( + [ + 'webhooks', + 'add', + 'https://h.example/x', + '--events', + 'server.listed, pass.granted', + '--key', + 'r4k_1', + '--api', + 'http://x', + ], + io, + ); + expect(calls[0]).toEqual({ + method: 'POST', + path: '/api/v1/webhooks', + body: { + url: 'https://h.example/x', + events: ['server.listed', 'pass.granted'], + description: '', + }, + }); + expect(text).toContain('whsec_s'); + await run(['webhooks', '--key', 'r4k_1', '--api', 'http://x'], io); + expect(text).toContain('wh_1 on https://h.example/x'); + await run(['webhooks', 'update', 'wh_1', '--off', '--key', 'r4k_1', '--api', 'http://x'], io); + expect(calls.at(-1)).toEqual({ + method: 'PATCH', + path: '/api/v1/webhooks/wh_1', + body: { enabled: false }, + }); + await run(['webhooks', 'test', 'wh_1', '--key', 'r4k_1', '--api', 'http://x'], io); + expect(calls.at(-1).path).toBe('/api/v1/webhooks/wh_1/test'); + }); +}); diff --git a/test/webhooks.test.js b/test/webhooks.test.js new file mode 100644 index 0000000..aa01cf6 --- /dev/null +++ b/test/webhooks.test.js @@ -0,0 +1,278 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +/** + * Customer webhooks end to end against a real Postgres (DATABASE_URL): the + * Bun.SQL adapter, /api/v1/webhooks behind the key, api_key.* events, and the + * watcher path (a sync tick lists and cuts an offer that matches a saved + * search). Deliveries go to a stub fetch, never to the network. + */ +const url = process.env.DATABASE_URL; +if (!url) { + test.skip('needs DATABASE_URL', () => {}); +} else { + const { migrate } = await import('@r4ck/db/migrate'); + const { sql } = await import('@r4ck/db'); + const cat = await import('@r4ck/db/catalog'); + const { syncTick } = await import('@r4ck/sync'); + const accounts = await import('@r4ck/db/accounts'); + const auth = await import('@r4ck/auth'); + const { app } = await import('../apps/web/src/app.js'); + const wh = await import('../apps/web/src/lib/webhooks.js'); + const { verifySignature } = wh; + + const EMAIL = 'hooks@webhooks.test.r4ck'; + const PROVIDER = 'whtest'; + const sent = []; + const fetchStub = async (to, init) => { + sent.push({ url: String(to), headers: new Headers(init.headers), body: String(init.body) }); + return new Response(null, { status: 204 }); + }; + const hub = wh.makeHooks({ + fetchImpl: fetchStub, + guard: { lookup: async () => ['93.184.216.34'] }, + }); + wh.setHooks(hub); + + const plan = (id, price, updated) => ({ + id, + kind: 'plan', + source: 'vultr-plans', + adapter: 'vultr-plans', + title: `wh plan ${id}`, + tags: ['plan'], + updated_at: updated, + data: { + provider: PROVIDER, + providerName: 'WhTest', + offer: { + id: `wh-${id}`, + name: `wh-${id}`, + url: 'https://whtest.example/x', + kind: 'vps', + compute: { vcpu: 2, ram_mb: 2048 }, + storage: [], + network: {}, + price: { amount: price, currency: 'USD', interval: 'month' }, + location: { countries: ['US'] }, + }, + }, + }); + + async function clean() { + await sql`delete from servers where provider = ${PROVIDER}`; + await sql`delete from providers where slug = ${PROVIDER}`; + const [u] = await sql`select id from users where email = ${EMAIL}`; + if (u) { + await wh.forgetOwner(u.id); + await sql`delete from users where id = ${u.id}`; + } + } + + const call = (key, path, init = {}) => + app.request(path, { + ...init, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'x-real-ip': '198.51.100.21', + ...(key ? { authorization: `Bearer ${key}` } : {}), + ...(init.headers ?? {}), + }, + }); + + let key; + let secret; + let endpointId; + + afterAll(async () => { + await clean(); + }); + + describe('Bun.SQL adapter', () => { + test('query(text, params) answers { rows } with $n params', async () => { + await migrate({ log: () => {} }); + const q = wh.pgQuery(sql); + const r = await q.query('select $1::int + 1 as n, $2::text as s', [41, 'x']); + expect(r.rows).toEqual([{ n: 42, s: 'x' }]); + expect((await q.query('select 1 as one')).rows[0].one).toBe(1); + }); + }); + + describe('/api/v1/webhooks', () => { + test('needs a key; lists the event types; creates an endpoint with a secret', async () => { + await clean(); + const user = await accounts.findOrCreateUser(EMAIL); + key = (await auth.createApiKey({ userId: user.id, name: 'hooks' })).key; + + expect((await call(null, '/api/v1/webhooks')).status).toBe(401); + + const list = await call(key, '/api/v1/webhooks'); + expect(list.status).toBe(200); + const body = await list.json(); + expect(body.endpoints).toEqual([]); + expect(body.event_types).toEqual(expect.arrayContaining(wh.EVENT_TYPES)); + + const bad = await call(key, '/api/v1/webhooks', { + method: 'POST', + body: JSON.stringify({ url: 'https://hooks.example.com/r4ck', events: ['nope.*'] }), + }); + expect(bad.status).toBe(400); + + const created = await call(key, '/api/v1/webhooks', { + method: 'POST', + body: JSON.stringify({ + url: 'https://hooks.example.com/r4ck', + events: ['server.*', 'api_key.created'], + description: 'test receiver', + }), + }); + expect(created.status).toBe(201); + const c = await created.json(); + secret = c.secret; + endpointId = c.endpoint.id; + expect(secret).toStartWith('whsec_'); + expect(c.endpoint.events).toEqual(['server.*', 'api_key.created']); + }); + + test('a new key emits api_key.created, signed so a Standard Webhooks receiver verifies it', async () => { + sent.length = 0; + const r = await call(key, '/api/v1/keys', { + method: 'POST', + body: JSON.stringify({ name: 'ci' }), + }); + expect(r.status).toBe(201); + const made = await r.json(); + await hub.drain(); + expect(sent.length).toBe(1); + const d = sent[0]; + expect(d.url).toBe('https://hooks.example.com/r4ck'); + expect(verifySignature({ headers: d.headers, body: d.body, secret }).ok).toBe(true); + expect(verifySignature({ headers: d.headers, body: d.body, secret: 'whsec_AAAA' }).ok).toBe( + false, + ); + const event = JSON.parse(d.body); + expect(event.type).toBe('api_key.created'); + expect(event.data).toEqual({ id: made.id, name: 'ci', prefix: made.prefix }); + expect(d.body).not.toContain(made.key); + + // Not subscribed to api_key.revoked: nothing goes out. + sent.length = 0; + await call(key, `/api/v1/keys/${made.id}`, { method: 'DELETE' }); + await hub.drain(); + expect(sent.length).toBe(0); + }); + }); + + describe('watchers', () => { + test('a sync tick that lists, then cuts, an offer on a saved search notifies its owner', async () => { + const saved = await call(key, '/api/v1/saved', { + method: 'POST', + body: JSON.stringify({ name: 'whtest boxes', query: { provider: PROVIDER } }), + }); + expect(saved.status).toBe(201); + // No walk due: only the recent page is read. + const state = (await cat.getSyncState('nichedb')) ?? {}; + await cat.setSyncState('nichedb', { + ...state, + walk: null, + walkCompletedAt: new Date().toISOString(), + }); + const tick = (items) => + syncTick({ + log: () => {}, + client: { recent: async () => items, page: async () => [] }, + walkEveryMs: 86_400_000, + onChanges: (changes) => wh.notifyWatchers(changes), + }); + + sent.length = 0; + await tick([plan(93001, 10, '2026-10-28T00:00:00Z')]); + await hub.drain(); + expect(sent.length).toBe(1); + let event = JSON.parse(sent[0].body); + expect(event.type).toBe('server.listed'); + expect(event.subject).toStartWith('saved_search:'); + expect(event.data.saved_search.name).toBe('whtest boxes'); + expect(event.data.total).toBe(1); + expect(event.data.servers[0].name).toBe('wh-93001'); + + // Same price again: nothing. + sent.length = 0; + await tick([plan(93001, 10, '2026-10-28T01:00:00Z')]); + await hub.drain(); + expect(sent.length).toBe(0); + + // Cheaper: server.price_dropped with the old price. + await tick([plan(93001, 8, '2026-10-28T02:00:00Z')]); + await hub.drain(); + expect(sent.length).toBe(1); + event = JSON.parse(sent[0].body); + expect(event.type).toBe('server.price_dropped'); + expect(event.data.servers[0].price.amount).toBe(8); + expect(event.data.servers[0].price_before).toEqual({ price: 10, currency: 'USD' }); + expect(verifySignature({ headers: sent[0].headers, body: sent[0].body, secret }).ok).toBe( + true, + ); + + // Dearer again: no event. + sent.length = 0; + await tick([plan(93001, 12, '2026-10-28T03:00:00Z')]); + await hub.drain(); + expect(sent.length).toBe(0); + + // A paused endpoint hears nothing. + await hub.updateEndpoint(String((await accounts.findOrCreateUser(EMAIL)).id), endpointId, { + enabled: false, + }); + await tick([plan(93002, 3, '2026-10-28T04:00:00Z')]); + await hub.drain(); + expect(sent.length).toBe(0); + }, 30_000); + }); + + describe('mcp', () => { + test('list_webhooks answers for the key; without one it is a tool error', async () => { + const rpc = (k, name) => + call(k, '/mcp', { + method: 'POST', + headers: { accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: {} }, + }), + }).then((r) => r.json()); + const ok = await rpc(key, 'list_webhooks'); + const out = ok.result.structuredContent ?? JSON.parse(ok.result.content[0].text); + expect(out.endpoints.map((e) => e.id)).toContain(endpointId); + expect(out.event_types).toEqual(wh.EVENT_TYPES); + const anon = await rpc(null, 'list_webhooks'); + expect(anon.result?.isError ?? Boolean(anon.error)).toBe(true); + }); + }); + + describe('settings page', () => { + test('shows the panel; the form adds an endpoint and shows its secret once', async () => { + const page = await call(key, '/settings', { headers: { accept: 'text/html' } }); + expect(page.status).toBe(200); + expect(await page.text()).toContain('id="webhooks"'); + const r = await app.request('/settings/webhooks', { + method: 'POST', + headers: { + authorization: `Bearer ${key}`, + accept: 'text/html', + 'content-type': 'application/x-www-form-urlencoded', + 'x-real-ip': '198.51.100.21', + }, + body: 'url=https%3A%2F%2Fhooks.example.com%2Fsecond&events=pass.granted&events=server.listed', + }); + expect(r.status).toBe(200); + const html = await r.text(); + expect(html).toContain('whsec_'); + const list = await (await call(key, '/api/v1/webhooks')).json(); + const second = list.endpoints.find((e) => e.url.endsWith('/second')); + expect(second.events).toEqual(['pass.granted', 'server.listed']); + }); + }); +}