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
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.
+ )}
+
+ {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']);
+ });
+ });
+}