Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
95 changes: 94 additions & 1 deletion apps/cli/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -65,6 +65,12 @@ export const COMMANDS = [
usage: 'invites [list|create [note]|revoke <id>]',
help: 'Your scarce invites (both sides get pass days) and your link.',
},
{
name: 'webhooks',
usage:
'webhooks [list|add <url> [--events a,b] [--description d]|update <id> [--url u] [--events a,b] [--on|--off]|rm <id>|test <id>|rotate <id>|secret <id>|deliveries [id]|retry <delivery>]',
help: 'Signed webhooks for your account: offers matching a saved search, price drops, pass and key changes.',
},
{
name: 'open',
usage: 'open <id|sentence>',
Expand Down Expand Up @@ -488,6 +494,93 @@ export async function run(
}
throw new Error('usage: r4ck invites [list|create [note]|revoke <id>]');
}
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} <id> (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 <https url> [--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 <delivery id>');
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)}`);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@r4ck/web",
"version": "0.1.2",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "src/main.js",
Expand All @@ -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:*",
Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/lib/credits.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/llms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
134 changes: 131 additions & 3 deletions apps/web/src/lib/mcp/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand All @@ -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,
},
});
Expand Down
Loading
Loading