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: 2 additions & 0 deletions packages/backend/src/adapters/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ import * as newsapi from './intl/newsapi.json';
import * as nimble from './intl/nimble.json';
import * as nominatim from './intl/nominatim.json';
import * as nutshellCrm from './intl/nutshell-crm.json';
import * as oddsApi from './intl/odds-api.json';
import * as omnisend from './intl/omnisend.json';
import * as opentable from './intl/opentable.json';
import * as openweather from './intl/openweather.json';
Expand Down Expand Up @@ -391,6 +392,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [
nimble as unknown as AdapterDefinition,
nominatim as unknown as AdapterDefinition,
nutshellCrm as unknown as AdapterDefinition,
oddsApi as unknown as AdapterDefinition,
omnisend as unknown as AdapterDefinition,
opentable as unknown as AdapterDefinition,
openweather as unknown as AdapterDefinition,
Expand Down
130 changes: 130 additions & 0 deletions packages/backend/src/adapters/intl/odds-api.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
{
"slug": "odds-api",
"name": "Odds API",
"description": "Live and pre-match sports betting odds (football, basketball, tennis, baseball, American football, ice hockey, MMA, boxing and more) from dozens of bookmakers via odds-api.io. Look up sports, leagues and events, then read odds for a specific event from your selected bookmakers.",
"instructions": "This connector wraps the **odds-api.io v3** REST API. Get a free API key at https://odds-api.io and paste it as `ODDS_API_KEY` — it is injected automatically into every call, you never pass it as a parameter.\n\n**Mandatory workflow** — `odds_get_event_odds` needs a concrete `eventId` and at least one valid `bookmakers` name; you cannot call it blind:\n1. `odds_list_sports` → the valid sport **slug** (e.g. `football`, `basketball`, `tennis`). NOTE: there is no `soccer_world_cup` slug — football competitions are plain `football`, narrow them with the league slug from step 2.\n2. `odds_list_leagues` (pass the sport slug) → the league `slug` (e.g. `england-premier-league`).\n3. `odds_list_events` (pass the sport slug, optionally the league) → each event's numeric **`id`**. This is the `eventId` you pass to odds.\n4. `odds_list_bookmakers` → the exact bookmaker **names** you may query (e.g. `Bet365`, `Betano PE`). Pass them as a comma-separated `bookmakers` value. `all` is NOT accepted, and free plans are limited to a small number of selected bookmakers (the API returns a 403 listing your allowed bookmakers if you exceed it).\n5. `odds_get_event_odds` with `eventId` + `bookmakers` (+ optional `markets`).\n\n**Common errors** (the API returns a JSON `error` field):\n- `Missing eventId` / `Missing bookmakers` → required parameter omitted.\n- `<x> is not a valid bookmaker` → use a name from `odds_list_bookmakers`, not `all`.\n- `Access denied. You're allowed max N bookmakers` → your plan limits selected bookmakers; pick from the listed allowed set or upgrade.\n\n**Times** are ISO-8601 UTC (e.g. `2026-06-18T09:00:00Z`). Event `status` is one of `notstarted` / `inprogress` / `settled`.",
"region": "intl",
"category": "sports",
"icon": "odds-api",
"docsUrl": "https://docs.odds-api.io",
"requiredEnvVars": ["ODDS_API_KEY"],
"connector": {
"name": "Odds API",
"type": "REST",
"baseUrl": "https://api.odds-api.io/v3",
"authType": "NONE"
},
"tools": [
{
"name": "odds_list_sports",
"description": "List the sports supported by odds-api.io. Returns each sport's display `name` and `slug`. Use the slug for odds_list_leagues / odds_list_events (e.g. `football`, `basketball`, `tennis`, `baseball`, `american-football`, `ice-hockey`, `mixed-martial-arts`, `boxing`). There is no `soccer_world_cup` slug — use `football` plus a league slug.",
"parameters": {
"type": "object",
"properties": {}
},
"endpointMapping": {
"method": "GET",
"path": "/sports",
"queryParams": {
"apiKey": "{{ODDS_API_KEY}}"
}
}
},
{
"name": "odds_list_bookmakers",
"description": "List the bookmakers known to odds-api.io. Returns each bookmaker's `name` and whether it is `active`. The exact `name` value (e.g. `Bet365`, `1xbet`) is what you pass in the `bookmakers` parameter of odds_get_event_odds. Free plans only allow a limited number of selected bookmakers.",
"parameters": {
"type": "object",
"properties": {}
},
"endpointMapping": {
"method": "GET",
"path": "/bookmakers",
"queryParams": {
"apiKey": "{{ODDS_API_KEY}}"
}
}
},
{
"name": "odds_list_leagues",
"description": "List the leagues/competitions available for a sport. Returns each league's `name`, `slug` and `eventsCount`. Use the `slug` to narrow odds_list_events.",
"parameters": {
"type": "object",
"properties": {
"sport": {
"type": "string",
"description": "Sport slug from odds_list_sports, e.g. `football`."
}
},
"required": ["sport"]
},
"endpointMapping": {
"method": "GET",
"path": "/leagues",
"queryParams": {
"apiKey": "{{ODDS_API_KEY}}",
"sport": "$sport"
}
}
},
{
"name": "odds_list_events",
"description": "List events (matches/fixtures) for a sport, optionally filtered by league. Each event has a numeric `id` (the `eventId` for odds_get_event_odds), `home`/`away` team names, `date` (ISO-8601 UTC), `league` and `status` (`notstarted` / `inprogress` / `settled`).",
"parameters": {
"type": "object",
"properties": {
"sport": {
"type": "string",
"description": "Sport slug from odds_list_sports, e.g. `football`."
},
"league": {
"type": "string",
"description": "Optional league slug from odds_list_leagues to narrow the list."
}
},
"required": ["sport"]
},
"endpointMapping": {
"method": "GET",
"path": "/events",
"queryParams": {
"apiKey": "{{ODDS_API_KEY}}",
"sport": "$sport",
"league": "$league"
}
}
},
{
"name": "odds_get_event_odds",
"description": "Get betting odds for a single event from one or more bookmakers. BOTH `eventId` and `bookmakers` are required — get `eventId` from odds_list_events and valid bookmaker names from odds_list_bookmakers (the literal `all` is rejected, and your plan caps how many you may select). Returns the event plus a `bookmakers` map of odds by market.",
"parameters": {
"type": "object",
"properties": {
"eventId": {
"type": "string",
"description": "Numeric event id from odds_list_events. Required."
},
"bookmakers": {
"type": "string",
"description": "Comma-separated bookmaker names from odds_list_bookmakers, e.g. `Bet365` or `Bet365,Betano PE`. Required. Do NOT pass `all`."
},
"markets": {
"type": "string",
"description": "Optional comma-separated markets to return, e.g. `h2h` (match winner), `totals`, `spreads`. Omit to return all available markets."
}
},
"required": ["eventId", "bookmakers"]
},
"endpointMapping": {
"method": "GET",
"path": "/odds",
"queryParams": {
"apiKey": "{{ODDS_API_KEY}}",
"eventId": "$eventId",
"bookmakers": "$bookmakers",
"markets": "$markets"
}
}
}
]
}
22 changes: 6 additions & 16 deletions packages/backend/src/adapters/intl/whatsapp-business.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,39 +20,30 @@
"tools": [
{
"name": "whatsapp_list_phone_numbers",
"description": "List all phone numbers registered on the WhatsApp Business Account. Returns each number's `id` (the phoneNumberId you pass to send tools), `display_phone_number`, `verified_name` and `quality_rating`. Call this once after setup to discover which phoneNumberId to use.",
"description": "List all phone numbers registered on the WhatsApp Business Account. Returns each number's `id` (the phoneNumberId you pass to send tools), `display_phone_number`, `verified_name` and `quality_rating`. Call this once after setup to discover which phoneNumberId to use. The WABA ID is taken automatically from the WHATSAPP_BUSINESS_ACCOUNT_ID configured on this connector — you do NOT pass it.",
"parameters": {
"type": "object",
"properties": {
"businessAccountId": {
"type": "string",
"description": "WhatsApp Business Account ID (WABA ID). Use the value of WHATSAPP_BUSINESS_ACCOUNT_ID configured on this connector — pass it explicitly here on each call (path parameter)."
},
"limit": {
"type": "integer",
"description": "Max phone numbers to return per page (default 20, max 100)."
}
},
"required": ["businessAccountId"]
}
},
"endpointMapping": {
"method": "GET",
"path": "/{businessAccountId}/phone_numbers",
"path": "/{{WHATSAPP_BUSINESS_ACCOUNT_ID}}/phone_numbers",
"queryParams": {
"limit": "$limit"
}
}
},
{
"name": "whatsapp_list_message_templates",
"description": "List the message templates approved on the WhatsApp Business Account. Returns each template's `id`, `name`, `language`, `status` (APPROVED / PENDING / REJECTED) and `components` (header/body/footer/buttons structure). Use this to discover which templates you can send via whatsapp_send_template_message.",
"description": "List the message templates approved on the WhatsApp Business Account. Returns each template's `id`, `name`, `language`, `status` (APPROVED / PENDING / REJECTED) and `components` (header/body/footer/buttons structure). Use this to discover which templates you can send via whatsapp_send_template_message. The WABA ID is taken automatically from the WHATSAPP_BUSINESS_ACCOUNT_ID configured on this connector — you do NOT pass it.",
"parameters": {
"type": "object",
"properties": {
"businessAccountId": {
"type": "string",
"description": "WhatsApp Business Account ID (WABA ID)."
},
"limit": {
"type": "integer",
"description": "Max templates per page (default 20, max 100)."
Expand All @@ -61,12 +52,11 @@
"type": "string",
"description": "Comma-separated list of fields to return. Default: 'name,language,status,category,components,id'."
}
},
"required": ["businessAccountId"]
}
},
"endpointMapping": {
"method": "GET",
"path": "/{businessAccountId}/message_templates",
"path": "/{{WHATSAPP_BUSINESS_ACCOUNT_ID}}/message_templates",
"queryParams": {
"limit": "$limit",
"fields": "$fields"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { LoginTokenService } from '../../connectors/engines/login-token.service'
* Graph API version, that send-tool bodies carry the mandatory
* `messaging_product: "whatsapp"` discriminator, and that paths follow the
* Meta Cloud API routing convention (`/{phoneNumberId}/messages`,
* `/{businessAccountId}/message_templates`, etc.). Catches the most common
* `/{{WHATSAPP_BUSINESS_ACCOUNT_ID}}/message_templates`, etc.). Catches the most common
* failure modes: pinning back to a deprecated API version, omitting the
* messaging_product field, or accidentally pointing at the old On-Premises
* API base path.
Expand All @@ -27,6 +27,10 @@ import { LoginTokenService } from '../../connectors/engines/login-token.service'

interface Tool {
name: string;
parameters: {
properties?: Record<string, unknown>;
required?: string[];
};
endpointMapping: {
method: string;
path: string;
Expand Down Expand Up @@ -133,14 +137,22 @@ describe('whatsapp-business adapter — static spec conformance', () => {
expect(audioObj['caption']).toBeUndefined();
});

it('WABA-scoped tools target /{businessAccountId}/...', () => {
it('WABA-scoped tools inject the WABA id from {{WHATSAPP_BUSINESS_ACCOUNT_ID}}', () => {
const listPhones = a.tools.find((t) => t.name === 'whatsapp_list_phone_numbers')!;
expect(listPhones.endpointMapping.path).toBe('/{businessAccountId}/phone_numbers');
expect(listPhones.endpointMapping.path).toBe(
'/{{WHATSAPP_BUSINESS_ACCOUNT_ID}}/phone_numbers',
);
expect(listPhones.endpointMapping.method).toBe('GET');
// The model must NOT be asked for the WABA id — it comes from env.
expect(listPhones.parameters.properties).not.toHaveProperty('businessAccountId');
expect(listPhones.parameters.required ?? []).not.toContain('businessAccountId');

const listTemplates = a.tools.find((t) => t.name === 'whatsapp_list_message_templates')!;
expect(listTemplates.endpointMapping.path).toBe('/{businessAccountId}/message_templates');
expect(listTemplates.endpointMapping.path).toBe(
'/{{WHATSAPP_BUSINESS_ACCOUNT_ID}}/message_templates',
);
expect(listTemplates.endpointMapping.method).toBe('GET');
expect(listTemplates.parameters.properties).not.toHaveProperty('businessAccountId');
});

it('business profile tools target /{phoneNumberId}/whatsapp_business_profile', () => {
Expand Down
108 changes: 108 additions & 0 deletions packages/backend/src/connectors/engines/database.engine.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,114 @@ describe('DatabaseEngine', () => {
),
).rejects.toThrow('Only SELECT queries are allowed');
});

it('should allow read-only WITH … SELECT CTE queries', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const cte =
'WITH q AS (SELECT id, amount FROM sales WHERE amount > 0) ' +
'SELECT COUNT(*) AS n, SUM(amount) AS total FROM q';
await engine.execute(
{ baseUrl: 'postgres://host/db', authType: 'NONE' },
{ method: 'query', path: cte },
{},
);
expect(mockQuery).toHaveBeenCalledWith(cte);
});

it('should allow a multi-CTE read-only query', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const cte =
'WITH a AS (SELECT 1 AS x), b AS (SELECT 2 AS y) ' +
'SELECT * FROM a JOIN b ON true';
await engine.execute(
{ baseUrl: 'postgres://host/db', authType: 'NONE' },
{ method: 'query', path: cte },
{},
);
expect(mockQuery).toHaveBeenCalledWith(cte);
});

it('should block data-modifying CTEs (WITH … AS (INSERT …))', async () => {
await expect(
engine.execute(
{ baseUrl: 'postgres://host/db', authType: 'NONE' },
{
method: 'query',
path: 'WITH x AS (INSERT INTO users VALUES (1) RETURNING id) SELECT * FROM x',
},
{},
),
).rejects.toThrow('Blocked SQL keyword in CTE: INSERT');
});

it('should block data-modifying CTEs (WITH … AS (DELETE …))', async () => {
await expect(
engine.execute(
{ baseUrl: 'postgres://host/db', authType: 'NONE' },
{
method: 'query',
path: 'WITH d AS (DELETE FROM users RETURNING id) SELECT * FROM d',
},
{},
),
).rejects.toThrow('Blocked SQL keyword in CTE: DELETE');
});

it('should block stacked statements after a SELECT', async () => {
await expect(
engine.execute(
{ baseUrl: 'postgres://host/db', authType: 'NONE' },
{ method: 'query', path: 'SELECT 1; DROP TABLE users' },
{},
),
).rejects.toThrow('single SQL statement');
});

it('should not be fooled by keyword-looking string literals', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const q = "SELECT * FROM audit WHERE action = 'DELETE' AND note = 'a;b'";
await engine.execute(
{ baseUrl: 'postgres://host/db', authType: 'NONE' },
{ method: 'query', path: q },
{},
);
expect(mockQuery).toHaveBeenCalledWith(q);
});

it('should ignore keywords inside comments', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const q = 'SELECT id /* DROP TABLE x */ FROM t -- ; DELETE FROM t\n';
await engine.execute(
{ baseUrl: 'postgres://host/db', authType: 'NONE' },
{ method: 'query', path: q },
{},
);
expect(mockQuery).toHaveBeenCalledWith(q);
});

it('rejects an unterminated block comment quickly (no ReDoS)', async () => {
const hostile = `/*${'a/*'.repeat(30000)}`; // ~90k chars, under MAX_QUERY_LENGTH
const start = Date.now();
await expect(
engine.execute(
{ baseUrl: 'postgres://host/db', authType: 'NONE' },
{ method: 'query', path: hostile },
{},
),
).rejects.toThrow('Only SELECT queries are allowed');
expect(Date.now() - start).toBeLessThan(1000);
});

it('rejects an oversized query', async () => {
const huge = `SELECT '${'a'.repeat(100_001)}'`;
await expect(
engine.execute(
{ baseUrl: 'postgres://host/db', authType: 'NONE' },
{ method: 'query', path: huge },
{},
),
).rejects.toThrow('Query too long');
});
});

describe('SQL parameter binding (prepared statements)', () => {
Expand Down
Loading
Loading