diff --git a/.claude/rules/sim-architecture.md b/.claude/rules/sim-architecture.md index 9b6b37ecef9..bc52fd37001 100644 --- a/.claude/rules/sim-architecture.md +++ b/.claude/rules/sim-architecture.md @@ -29,7 +29,7 @@ apps/ └── realtime/ # Bun Socket.IO server (collaborative canvas) packages/ # @sim/* — audit, auth, db, logger, realtime-protocol, - # security, tsconfig, utils, workflow-authz, + # security, tsconfig, utils, platform-authz, # workflow-persistence, workflow-types ``` diff --git a/.cursor/rules/sim-architecture.mdc b/.cursor/rules/sim-architecture.mdc index 08c3df6bf5b..90bac74294d 100644 --- a/.cursor/rules/sim-architecture.mdc +++ b/.cursor/rules/sim-architecture.mdc @@ -28,7 +28,7 @@ apps/ └── realtime/ # Bun Socket.IO server (collaborative canvas) packages/ # @sim/* — audit, auth, db, logger, realtime-protocol, - # security, tsconfig, utils, workflow-authz, + # security, tsconfig, utils, platform-authz, # workflow-persistence, workflow-types ``` diff --git a/.cursor/rules/sim-testing.mdc b/.cursor/rules/sim-testing.mdc index ca3ceb1e946..515784d541b 100644 --- a/.cursor/rules/sim-testing.mdc +++ b/.cursor/rules/sim-testing.mdc @@ -22,7 +22,7 @@ These modules are mocked globally — do NOT re-mock them in test files unless y - `@/stores/console/store`, `@/stores/terminal`, `@/stores/execution/store` - `@/blocks/registry` - `@trigger.dev/sdk` -- `@sim/workflow-authz` → `workflowAuthzMock` +- `@sim/platform-authz/workflow` → `workflowAuthzMock` ## Structure diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f4e3df0d31c..d1e18087f88 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -8,7 +8,7 @@ Thank you for your interest in contributing to Sim! Our goal is to provide devel > - `apps/sim/` — the main Next.js application (App Router, ReactFlow, Zustand, Shadcn, Tailwind CSS). > - `apps/realtime/` — a small Bun + Socket.IO server that powers the collaborative canvas. Shares DB and Better Auth secrets with `apps/sim` via `@sim/*` packages. > - `apps/docs/` — Fumadocs-based documentation site. -> - `packages/` — shared workspace packages (`@sim/db`, `@sim/auth`, `@sim/audit`, `@sim/workflow-types`, `@sim/workflow-persistence`, `@sim/workflow-authz`, `@sim/realtime-protocol`, `@sim/security`, `@sim/logger`, `@sim/utils`, `@sim/testing`, `@sim/tsconfig`). +> - `packages/` — shared workspace packages (`@sim/db`, `@sim/auth`, `@sim/audit`, `@sim/workflow-types`, `@sim/workflow-persistence`, `@sim/platform-authz`, `@sim/realtime-protocol`, `@sim/security`, `@sim/logger`, `@sim/utils`, `@sim/testing`, `@sim/tsconfig`). > > Strict one-way dependency flow: `apps/* → packages/*`. Packages never import from apps. Please ensure your contributions follow this and our best practices for clarity, maintainability, and consistency. diff --git a/.gitignore b/.gitignore index c38b288a683..a700a66602a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ # bun specific bun-debug.log* +# cursor debug logs +.cursor/debug-*.log + # this repo uses bun.lock; package-lock.json files are accidental package-lock.json @@ -44,6 +47,11 @@ dump.rdb .env.test .env.production +# editor swap files +*.swp +*.swo +*.swn + # vercel .vercel diff --git a/AGENTS.md b/AGENTS.md index 78feaedb30a..9ce16b909d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,11 +51,11 @@ packages/ ├── auth/ # @sim/auth — shared Better Auth verifier ├── db/ # @sim/db — drizzle schema + client ├── logger/ # @sim/logger +├── platform-authz/ # @sim/platform-authz — workspace + workflow authz (subpath exports) ├── realtime-protocol/ # @sim/realtime-protocol — socket op constants + zod schemas ├── security/ # @sim/security — safeCompare ├── tsconfig/ # shared tsconfig presets ├── utils/ # @sim/utils -├── workflow-authz/ # @sim/workflow-authz ├── workflow-persistence/ # @sim/workflow-persistence └── workflow-types/ # @sim/workflow-types — pure BlockState/Loop/Parallel types ``` @@ -409,7 +409,7 @@ Use Vitest. Test files: `feature.ts` → `feature.test.ts`. See `.cursor/rules/s ### Global Mocks (vitest.setup.ts) -`@sim/db`, `@sim/db/schema`, `drizzle-orm`, `@sim/logger`, `@sim/workflow-authz`, `@/blocks/registry`, `@/lib/auth`, `@/lib/auth/hybrid`, `@/lib/core/utils/request`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. (The `vi.mock('@/lib/auth', ...)` in the example below is an override of the global mock so `getSession` can be controlled per-test.) +`@sim/db`, `@sim/db/schema`, `drizzle-orm`, `@sim/logger`, `@sim/platform-authz/workflow`, `@/blocks/registry`, `@/lib/auth`, `@/lib/auth/hybrid`, `@/lib/core/utils/request`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. (The `vi.mock('@/lib/auth', ...)` in the example below is an override of the global mock so `getSession` can be controlled per-test.) ### Standard Test Pattern diff --git a/CLAUDE.md b/CLAUDE.md index 78feaedb30a..9ce16b909d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,11 +51,11 @@ packages/ ├── auth/ # @sim/auth — shared Better Auth verifier ├── db/ # @sim/db — drizzle schema + client ├── logger/ # @sim/logger +├── platform-authz/ # @sim/platform-authz — workspace + workflow authz (subpath exports) ├── realtime-protocol/ # @sim/realtime-protocol — socket op constants + zod schemas ├── security/ # @sim/security — safeCompare ├── tsconfig/ # shared tsconfig presets ├── utils/ # @sim/utils -├── workflow-authz/ # @sim/workflow-authz ├── workflow-persistence/ # @sim/workflow-persistence └── workflow-types/ # @sim/workflow-types — pure BlockState/Loop/Parallel types ``` @@ -409,7 +409,7 @@ Use Vitest. Test files: `feature.ts` → `feature.test.ts`. See `.cursor/rules/s ### Global Mocks (vitest.setup.ts) -`@sim/db`, `@sim/db/schema`, `drizzle-orm`, `@sim/logger`, `@sim/workflow-authz`, `@/blocks/registry`, `@/lib/auth`, `@/lib/auth/hybrid`, `@/lib/core/utils/request`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. (The `vi.mock('@/lib/auth', ...)` in the example below is an override of the global mock so `getSession` can be controlled per-test.) +`@sim/db`, `@sim/db/schema`, `drizzle-orm`, `@sim/logger`, `@sim/platform-authz/workflow`, `@/blocks/registry`, `@/lib/auth`, `@/lib/auth/hybrid`, `@/lib/core/utils/request`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. (The `vi.mock('@/lib/auth', ...)` in the example below is an override of the global mock so `getSession` can be controlled per-test.) ### Standard Test Pattern diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 13fa62588bf..9792fb9b7c2 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1958,6 +1958,24 @@ export function WhatsAppIcon(props: SVGProps) { ) } +export function SportmonksIcon(props: SVGProps) { + return ( + + + + + ) +} + export function SquareIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 22cf6c737db..05866ee2fd6 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -195,6 +195,7 @@ import { SixtyfourIcon, SlackIcon, SmtpIcon, + SportmonksIcon, SQSIcon, SquareIcon, SshIcon, @@ -449,6 +450,7 @@ export const blockTypeToIconMap: Record = { sixtyfour: SixtyfourIcon, slack: SlackIcon, smtp: SmtpIcon, + sportmonks: SportmonksIcon, sqs: SQSIcon, square: SquareIcon, ssh: SshIcon, diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 5e08cf704b0..de492727e78 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -196,6 +196,7 @@ "sixtyfour", "slack", "smtp", + "sportmonks", "sqs", "square", "ssh", diff --git a/apps/docs/content/docs/en/integrations/sportmonks.mdx b/apps/docs/content/docs/en/integrations/sportmonks.mdx new file mode 100644 index 00000000000..f3d3d673742 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/sportmonks.mdx @@ -0,0 +1,7045 @@ +--- +title: Sportmonks +description: Access Sportmonks football, motorsport, odds, and reference data +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate the Sportmonks sports data APIs into the workflow from a single block. Football: fixtures, livescores, leagues, seasons, stages, rounds, teams, squads, players, coaches, referees, venues, standings, topscorers, transfers, schedules, commentaries, TV stations, rivals, expected goals (xG), and predictions. Motorsport: sessions, drivers, teams, championship standings, laps, and pitstops. Odds: pre-match and in-play odds, bookmakers, and markets. Core: continents, countries, regions, cities, types, and time zones. + + + +## Actions + +### `sportmonks_football_expected_by_player` + +Retrieve lineup-level expected goals (xG) values per player from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fixture;player;team;type\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `expected` | array | Array of player-level expected goals \(xG\) entries | +| ↳ `id` | number | Unique id of the expected value | +| ↳ `fixture_id` | number | Fixture related to the value | +| ↳ `player_id` | number | Player related to the value | +| ↳ `team_id` | number | Team related to the value | +| ↳ `lineup_id` | number | Lineup record the player relates to | +| ↳ `type_id` | number | Type of the expected value | +| ↳ `data` | object | The expected value payload | +| ↳ `value` | number | The xG value | + +### `sportmonks_football_expected_by_team` + +Retrieve fixture-level expected goals (xG) values per team from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fixture;participant;type\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `expected` | array | Array of team-level expected goals \(xG\) entries | +| ↳ `id` | number | Unique id of the expected value | +| ↳ `fixture_id` | number | Fixture related to the value | +| ↳ `type_id` | number | Type of the expected value | +| ↳ `participant_id` | number | Team related to the expected value | +| ↳ `data` | object | The expected value payload | +| ↳ `value` | number | The xG value | +| ↳ `location` | string | Home or away | + +### `sportmonks_football_get_all_commentaries` + +Retrieve all textual commentaries available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fixture;player\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commentaries` | array | Array of commentary entries | +| ↳ `id` | number | Unique id of the commentary | +| ↳ `fixture_id` | number | Fixture related to the commentary | +| ↳ `comment` | string | The commentary text | +| ↳ `minute` | number | Match minute of the comment | +| ↳ `extra_minute` | number | Extra \(injury\) minute of the comment | +| ↳ `is_goal` | boolean | Whether the comment is a goal | +| ↳ `is_important` | boolean | Whether the comment is important | +| ↳ `order` | number | Order of the comment | + +### `sportmonks_football_get_all_fixtures` + +Retrieve all football fixtures available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of fixture objects | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_all_players` + +Retrieve all football players available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. nationality;position\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order players by id \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `players` | array | Array of player objects | +| ↳ `id` | number | Unique id of the player | +| ↳ `sport_id` | number | Sport of the player | +| ↳ `country_id` | number | Country of birth of the player | +| ↳ `nationality_id` | number | Nationality of the player | +| ↳ `city_id` | number | City of birth of the player | +| ↳ `position_id` | number | Position of the player | +| ↳ `detailed_position_id` | number | Detailed position of the player | +| ↳ `type_id` | number | Type of the player | +| ↳ `common_name` | string | Name the player is known for | +| ↳ `firstname` | string | First name of the player | +| ↳ `lastname` | string | Last name of the player | +| ↳ `name` | string | Name of the player | +| ↳ `display_name` | string | Display name of the player | +| ↳ `image_path` | string | URL to the player headshot | +| ↳ `height` | number | Height of the player in cm | +| ↳ `weight` | number | Weight of the player in kg | +| ↳ `date_of_birth` | string | Date of birth of the player | +| ↳ `gender` | string | Gender of the player | + +### `sportmonks_football_get_all_rivals` + +Retrieve all teams with their rivals information from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. team;rival\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rivals` | array | Array of rival relationships | +| ↳ `sport_id` | number | Sport of the rival | +| ↳ `team_id` | number | Team the rivalry belongs to | +| ↳ `rival_id` | number | Rival team id | + +### `sportmonks_football_get_all_teams` + +Retrieve all football teams available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;venue\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order teams by id \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teams` | array | Array of team objects | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Home venue of the team | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the last played match | + +### `sportmonks_football_get_all_transfer_rumours` + +Retrieve all transfer rumours available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;fromTeam;toTeam\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transferRumours` | array | Array of transfer rumour objects | +| ↳ `id` | number | Unique id of the transfer rumour | +| ↳ `sport_id` | number | Sport of the transfer rumour | +| ↳ `player_id` | number | Player the rumour relates to | +| ↳ `position_id` | number | Position id of the player | +| ↳ `from_team_id` | number | Team the player would transfer from | +| ↳ `to_team_id` | number | Team the player would transfer to | +| ↳ `transfer_fee_id` | number | Transfer fee id of the rumour | +| ↳ `probability` | string | Probability of the rumour \(e.g. LOW\) | +| ↳ `source_name` | string | Name of the source of the rumour | +| ↳ `source_url` | string | URL of the source of the rumour | +| ↳ `amount` | number | Estimated transfer fee amount | +| ↳ `currency` | string | Currency of the amount | +| ↳ `date` | string | Date of the rumour | +| ↳ `type_id` | number | Type of the transfer rumour | + +### `sportmonks_football_get_all_transfers` + +Retrieve all transfers available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;fromTeam;toTeam\) | +| `filters` | string | No | Filters to apply \(e.g. transferTypes:219,220\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transfers` | array | Array of transfer objects | +| ↳ `id` | number | Unique id of the transfer | +| ↳ `sport_id` | number | Sport of the transfer | +| ↳ `player_id` | number | Player who transferred | +| ↳ `type_id` | number | Type of the transfer | +| ↳ `from_team_id` | number | Team the player transferred from | +| ↳ `to_team_id` | number | Team the player transferred to | +| ↳ `position_id` | number | Position id of the transfer | +| ↳ `detailed_position_id` | number | Detailed position id of the transfer | +| ↳ `date` | string | Date of the transfer | +| ↳ `career_ended` | boolean | Whether the transfer ended the career | +| ↳ `completed` | boolean | Whether the transfer is completed | +| ↳ `amount` | number | Transfer fee amount | + +### `sportmonks_football_get_brackets_by_season` + +Retrieve the knockout-stage tournament bracket (stages and progression edges) for a season ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `brackets` | json | Bracket object containing stages \(fixtures grouped by knockout round\) and edges \(progression paths between fixtures\) | + +### `sportmonks_football_get_coach` + +Retrieve a single football coach by their ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `coachId` | string | Yes | The unique id of the coach | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;teams;statistics\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `coach` | object | The requested coach object | +| ↳ `id` | number | Unique id of the coach | +| ↳ `player_id` | number | Player related to the coach | +| ↳ `sport_id` | number | Sport of the coach | +| ↳ `country_id` | number | Country of the coach | +| ↳ `nationality_id` | number | Nationality of the coach | +| ↳ `city_id` | number | Birth city of the coach | +| ↳ `common_name` | string | Common name of the coach | +| ↳ `firstname` | string | First name of the coach | +| ↳ `lastname` | string | Last name of the coach | +| ↳ `name` | string | Name of the coach | +| ↳ `display_name` | string | Display name of the coach | +| ↳ `image_path` | string | URL to the coach headshot | +| ↳ `height` | number | Height of the coach in cm | +| ↳ `weight` | number | Weight of the coach in kg | +| ↳ `date_of_birth` | string | Date of birth of the coach | +| ↳ `gender` | string | Gender of the coach | + +### `sportmonks_football_get_coaches` + +Retrieve all football coaches available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;teams\) | +| `filters` | string | No | Filters to apply \(e.g. coachCountries:462\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `coaches` | array | Array of coach objects | +| ↳ `id` | number | Unique id of the coach | +| ↳ `player_id` | number | Player related to the coach | +| ↳ `sport_id` | number | Sport of the coach | +| ↳ `country_id` | number | Country of the coach | +| ↳ `nationality_id` | number | Nationality of the coach | +| ↳ `city_id` | number | Birth city of the coach | +| ↳ `common_name` | string | Common name of the coach | +| ↳ `firstname` | string | First name of the coach | +| ↳ `lastname` | string | Last name of the coach | +| ↳ `name` | string | Name of the coach | +| ↳ `display_name` | string | Display name of the coach | +| ↳ `image_path` | string | URL to the coach headshot | +| ↳ `height` | number | Height of the coach in cm | +| ↳ `weight` | number | Weight of the coach in kg | +| ↳ `date_of_birth` | string | Date of birth of the coach | +| ↳ `gender` | string | Gender of the coach | + +### `sportmonks_football_get_coaches_by_country` + +Retrieve all coaches for a country ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `countryId` | string | Yes | The unique id of the country | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;nationality\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `coaches` | array | Array of coach objects for the country | +| ↳ `id` | number | Unique id of the coach | +| ↳ `player_id` | number | Player related to the coach | +| ↳ `sport_id` | number | Sport of the coach | +| ↳ `country_id` | number | Country of the coach | +| ↳ `nationality_id` | number | Nationality of the coach | +| ↳ `city_id` | number | Birth city of the coach | +| ↳ `common_name` | string | Common name of the coach | +| ↳ `firstname` | string | First name of the coach | +| ↳ `lastname` | string | Last name of the coach | +| ↳ `name` | string | Name of the coach | +| ↳ `display_name` | string | Display name of the coach | +| ↳ `image_path` | string | URL to the coach headshot | +| ↳ `height` | number | Height of the coach in cm | +| ↳ `weight` | number | Weight of the coach in kg | +| ↳ `date_of_birth` | string | Date of birth of the coach | +| ↳ `gender` | string | Gender of the coach | + +### `sportmonks_football_get_commentaries_by_fixture` + +Retrieve textual commentary for a fixture by fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;relatedPlayer\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commentaries` | array | Array of commentary entries for the fixture | +| ↳ `id` | number | Unique id of the commentary | +| ↳ `fixture_id` | number | Fixture related to the commentary | +| ↳ `comment` | string | The commentary text | +| ↳ `minute` | number | Match minute of the comment | +| ↳ `extra_minute` | number | Extra \(injury\) minute of the comment | +| ↳ `is_goal` | boolean | Whether the comment is a goal | +| ↳ `is_important` | boolean | Whether the comment is important | +| ↳ `order` | number | Order of the comment | + +### `sportmonks_football_get_current_leagues_by_team` + +Retrieve all current leagues for a team ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;currentSeason\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order leagues \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of current league objects for the team | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | number | Whether the league is active \(1\) or inactive \(0\) | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date the last fixture was played | +| ↳ `category` | number | Importance category of the league \(1-4\) | + +### `sportmonks_football_get_expected_lineups_by_player` + +Retrieve the premium expected lineups for a player ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `playerId` | string | Yes | The unique id of the player | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;fixture\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `expectedLineups` | array | Array of expected lineup entries for the player | +| ↳ `id` | number | Unique id of the expected lineup record | +| ↳ `sport_id` | number | Sport of the expected lineup | +| ↳ `fixture_id` | number | Fixture the expected lineup relates to | +| ↳ `player_id` | number | Player in the expected lineup | +| ↳ `team_id` | number | Team of the expected lineup player | +| ↳ `formation_field` | string | Formation field of the player | +| ↳ `position_id` | number | Position id of the player | +| ↳ `detailed_position_id` | number | Detailed position id of the player | +| ↳ `type_id` | number | Type of the expected lineup record | +| ↳ `formation_position` | number | Position of the player in the formation | +| ↳ `player_name` | string | Name of the player | +| ↳ `jersey_number` | number | Jersey number of the player | + +### `sportmonks_football_get_expected_lineups_by_team` + +Retrieve the premium expected lineups for a team ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;fixture\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `expectedLineups` | array | Array of expected lineup entries for the team | +| ↳ `id` | number | Unique id of the expected lineup record | +| ↳ `sport_id` | number | Sport of the expected lineup | +| ↳ `fixture_id` | number | Fixture the expected lineup relates to | +| ↳ `player_id` | number | Player in the expected lineup | +| ↳ `team_id` | number | Team of the expected lineup player | +| ↳ `formation_field` | string | Formation field of the player | +| ↳ `position_id` | number | Position id of the player | +| ↳ `detailed_position_id` | number | Detailed position id of the player | +| ↳ `type_id` | number | Type of the expected lineup record | +| ↳ `formation_position` | number | Position of the player in the formation | +| ↳ `player_name` | string | Name of the player | +| ↳ `jersey_number` | number | Jersey number of the player | + +### `sportmonks_football_get_extended_team_squad` + +Retrieve all squad entries for a team (based on current seasons) by team ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;position\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `squad` | array | Array of extended squad entries for the team | +| ↳ `id` | number | Unique id of the squad record | +| ↳ `transfer_id` | number | Transfer id of the squad record | +| ↳ `player_id` | number | Player in the squad | +| ↳ `team_id` | number | Team of the squad | +| ↳ `position_id` | number | Position of the player in the squad | +| ↳ `detailed_position_id` | number | Detailed position of the player in the squad | +| ↳ `jersey_number` | number | Jersey number of the player | +| ↳ `start` | string | Start contract date of the player | +| ↳ `end` | string | End contract date of the player | + +### `sportmonks_football_get_fixture` + +Retrieve a single football fixture by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores;events;lineups;statistics\) | +| `filters` | string | No | Filters to apply \(e.g. eventTypes:14\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixture` | object | The requested fixture object | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_fixtures_by_date` + +Retrieve all football fixtures on a specific date (YYYY-MM-DD) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `date` | string | Yes | The date to fetch fixtures for, in YYYY-MM-DD format | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores;league\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501,271\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order fixtures by starting_at \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of fixture objects for the requested date | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_fixtures_by_date_range` + +Retrieve football fixtures between two dates (YYYY-MM-DD) from Sportmonks. Max range is 100 days. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `startDate` | string | Yes | Start date in YYYY-MM-DD format | +| `endDate` | string | Yes | End date in YYYY-MM-DD format \(max 100 days after start\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501,271\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order fixtures by starting_at \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of fixture objects within the requested date range | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_fixtures_by_date_range_for_team` + +Retrieve fixtures for a team within a date range (YYYY-MM-DD) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `startDate` | string | Yes | Start date in YYYY-MM-DD format | +| `endDate` | string | Yes | End date in YYYY-MM-DD format | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order fixtures by starting_at \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of fixture objects for the team within the date range | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_fixtures_by_ids` + +Retrieve multiple football fixtures by a comma-separated list of IDs (max 50) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `ids` | string | Yes | Comma-separated fixture IDs \(e.g. 18535517,18535518\). Maximum of 50 IDs | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of fixture objects for the requested IDs | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_grouped_standings_by_round` + +Retrieve the standing table for a round ID grouped by group where applicable from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `roundId` | string | Yes | The unique id of the round | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply \(e.g. standingGroups:246697\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `standings` | json | Standings for the round: an array of groups \(each with id, name and a standings array\) when groups exist, otherwise a flat array of standing entries | + +### `sportmonks_football_get_head_to_head` + +Retrieve the head-to-head fixtures between two teams from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `team1` | string | Yes | The id of the first team | +| `team2` | string | Yes | The id of the second team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order fixtures by starting_at \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of head-to-head fixture objects between the two teams | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_inplay_livescores` + +Retrieve all fixtures that are currently being played (in-play) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores;events\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of in-play fixture objects | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_latest_coaches` + +Retrieve all coaches that have received updates in the past two hours + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;nationality\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `coaches` | array | Array of recently updated coach objects | +| ↳ `id` | number | Unique id of the coach | +| ↳ `player_id` | number | Player related to the coach | +| ↳ `sport_id` | number | Sport of the coach | +| ↳ `country_id` | number | Country of the coach | +| ↳ `nationality_id` | number | Nationality of the coach | +| ↳ `city_id` | number | Birth city of the coach | +| ↳ `common_name` | string | Common name of the coach | +| ↳ `firstname` | string | First name of the coach | +| ↳ `lastname` | string | Last name of the coach | +| ↳ `name` | string | Name of the coach | +| ↳ `display_name` | string | Display name of the coach | +| ↳ `image_path` | string | URL to the coach headshot | +| ↳ `height` | number | Height of the coach in cm | +| ↳ `weight` | number | Weight of the coach in kg | +| ↳ `date_of_birth` | string | Date of birth of the coach | +| ↳ `gender` | string | Gender of the coach | + +### `sportmonks_football_get_latest_fixtures` + +Retrieve all fixtures that have received updates within the last 10 seconds + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of recently updated fixture objects | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_latest_livescores` + +Retrieve all livescores that have received updates within the last 10 seconds + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of recently updated live fixture objects | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_latest_players` + +Retrieve all players that have received updates in the past two hours + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. nationality;position\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `players` | array | Array of recently updated player objects | +| ↳ `id` | number | Unique id of the player | +| ↳ `sport_id` | number | Sport of the player | +| ↳ `country_id` | number | Country of birth of the player | +| ↳ `nationality_id` | number | Nationality of the player | +| ↳ `city_id` | number | City of birth of the player | +| ↳ `position_id` | number | Position of the player | +| ↳ `detailed_position_id` | number | Detailed position of the player | +| ↳ `type_id` | number | Type of the player | +| ↳ `common_name` | string | Name the player is known for | +| ↳ `firstname` | string | First name of the player | +| ↳ `lastname` | string | Last name of the player | +| ↳ `name` | string | Name of the player | +| ↳ `display_name` | string | Display name of the player | +| ↳ `image_path` | string | URL to the player headshot | +| ↳ `height` | number | Height of the player in cm | +| ↳ `weight` | number | Weight of the player in kg | +| ↳ `date_of_birth` | string | Date of birth of the player | +| ↳ `gender` | string | Gender of the player | + +### `sportmonks_football_get_latest_totw` + +Retrieve the latest Team of the Week (TOTW) for a league ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `leagueId` | string | Yes | The unique id of the league | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fixture;team;player;round\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totw` | array | Array of the latest Team of the Week entries for the league | +| ↳ `id` | number | Unique id of the TOTW entry | +| ↳ `player_id` | number | Player of the team of the week | +| ↳ `fixture_id` | number | Fixture the TOTW player played in | +| ↳ `round_id` | number | Round the fixture is played at | +| ↳ `team_id` | number | Team the TOTW player played for | +| ↳ `rating` | string | Rating of the TOTW player | +| ↳ `formation_position` | number | Player position in the TOTW formation | +| ↳ `formation` | string | The TOTW's formation | + +### `sportmonks_football_get_latest_transfers` + +Retrieve the latest transfers available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;fromTeam;toTeam\) | +| `filters` | string | No | Filters to apply \(e.g. transferTypes:219,220\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transfers` | array | Array of the latest transfer objects | +| ↳ `id` | number | Unique id of the transfer | +| ↳ `sport_id` | number | Sport of the transfer | +| ↳ `player_id` | number | Player who transferred | +| ↳ `type_id` | number | Type of the transfer | +| ↳ `from_team_id` | number | Team the player transferred from | +| ↳ `to_team_id` | number | Team the player transferred to | +| ↳ `position_id` | number | Position id of the transfer | +| ↳ `detailed_position_id` | number | Detailed position id of the transfer | +| ↳ `date` | string | Date of the transfer | +| ↳ `career_ended` | boolean | Whether the transfer ended the career | +| ↳ `completed` | boolean | Whether the transfer is completed | +| ↳ `amount` | number | Transfer fee amount | + +### `sportmonks_football_get_league` + +Retrieve a single football league by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `leagueId` | string | Yes | The unique id of the league | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;currentSeason;seasons\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `league` | object | The requested league object | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | number | Whether the league is active \(1\) or inactive \(0\) | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date the last fixture was played | +| ↳ `category` | number | Importance category of the league \(1-4\) | + +### `sportmonks_football_get_leagues` + +Retrieve all football leagues available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;currentSeason\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order leagues \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of league objects | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | number | Whether the league is active \(1\) or inactive \(0\) | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date the last fixture was played | +| ↳ `category` | number | Importance category of the league \(1-4\) | + +### `sportmonks_football_get_leagues_by_country` + +Retrieve all leagues for a country ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `countryId` | string | Yes | The unique id of the country | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;currentSeason\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order leagues \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of league objects for the country | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | number | Whether the league is active \(1\) or inactive \(0\) | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date the last fixture was played | +| ↳ `category` | number | Importance category of the league \(1-4\) | + +### `sportmonks_football_get_leagues_by_date` + +Retrieve all leagues with fixtures on a given date (YYYY-MM-DD) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `date` | string | Yes | The fixture date in YYYY-MM-DD format | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;currentSeason\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order leagues \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of league objects with fixtures on the requested date | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | number | Whether the league is active \(1\) or inactive \(0\) | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date the last fixture was played | +| ↳ `category` | number | Importance category of the league \(1-4\) | + +### `sportmonks_football_get_leagues_by_team` + +Retrieve all current and historical leagues for a team ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;currentSeason\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order leagues \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of current and historical league objects for the team | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | number | Whether the league is active \(1\) or inactive \(0\) | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date the last fixture was played | +| ↳ `category` | number | Importance category of the league \(1-4\) | + +### `sportmonks_football_get_live_leagues` + +Retrieve all leagues that have fixtures currently being played from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;currentSeason\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order leagues \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of currently live league objects | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | number | Whether the league is active \(1\) or inactive \(0\) | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date the last fixture was played | +| ↳ `category` | number | Importance category of the league \(1-4\) | + +### `sportmonks_football_get_live_probabilities` + +Retrieve all live (in-play) prediction probabilities from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. type;fixture\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `predictions` | array | Array of live probability prediction objects | +| ↳ `id` | number | Unique id of the live prediction record | +| ↳ `fixture_id` | number | Fixture the prediction belongs to | +| ↳ `period_id` | number | Match period the prediction was recorded in | +| ↳ `minute` | number | Match minute the prediction was generated | +| ↳ `predictions` | json | Home win, away win and draw probabilities as percentages | +| ↳ `type_id` | number | Type of the prediction \(237 for fulltime result\) | + +### `sportmonks_football_get_live_probabilities_by_fixture` + +Retrieve all live (in-play) prediction probabilities for a fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. type;fixture\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `predictions` | array | Array of live probability prediction objects for the fixture | +| ↳ `id` | number | Unique id of the live prediction record | +| ↳ `fixture_id` | number | Fixture the prediction belongs to | +| ↳ `period_id` | number | Match period the prediction was recorded in | +| ↳ `minute` | number | Match minute the prediction was generated | +| ↳ `predictions` | json | Home win, away win and draw probabilities as percentages | +| ↳ `type_id` | number | Type of the prediction \(237 for fulltime result\) | + +### `sportmonks_football_get_live_standings_by_league` + +Retrieve the live standing table for a league ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `leagueId` | string | Yes | The unique id of the league | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply \(e.g. standingGroups:246697\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `standings` | array | Array of live standing entries for the league | +| ↳ `id` | number | Unique id of the standing | +| ↳ `participant_id` | number | Team related to the standing | +| ↳ `sport_id` | number | Sport related to the standing | +| ↳ `league_id` | number | League related to the standing | +| ↳ `season_id` | number | Season related to the standing | +| ↳ `stage_id` | number | Stage related to the standing | +| ↳ `group_id` | number | Group related to the standing | +| ↳ `round_id` | number | Round related to the standing | +| ↳ `standing_rule_id` | number | Standing rule related to the standing | +| ↳ `position` | number | Position of the team in the standing | +| ↳ `result` | string | Movement of the team in the standing | +| ↳ `points` | number | Points the team has gathered | + +### `sportmonks_football_get_livescores` + +Retrieve fixtures starting within 15 minutes and currently in progress from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores;events\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of live fixture objects | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_match_facts` + +Retrieve all available match facts within your Sportmonks subscription (beta) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. type;sport;fixture\) | +| `filters` | string | No | Filters to apply \(e.g. matchFactTypes:76088\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `matchFacts` | array | Array of match fact objects | +| ↳ `id` | number | Unique id of the match fact | +| ↳ `sport_id` | number | Sport of the match fact | +| ↳ `fixture_id` | number | Fixture related to the match fact | +| ↳ `type_id` | number | Type of the match fact | +| ↳ `participant` | string | Team the fact relates to \(home or away\) | +| ↳ `basis` | string | Basis of the match fact \(e.g. h2h, overall\) | +| ↳ `data` | json | Match fact data payload \(counts and percentages\) | +| ↳ `natural_language` | string | Human-readable description of the match fact | +| ↳ `category` | string | Category of the match fact | +| ↳ `scope` | string | Scope of the match fact | + +### `sportmonks_football_get_match_facts_by_date_range` + +Retrieve match facts within a date range (YYYY-MM-DD) from Sportmonks (beta) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `startDate` | string | Yes | Start date in YYYY-MM-DD format | +| `endDate` | string | Yes | End date in YYYY-MM-DD format | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. type;sport;fixture\) | +| `filters` | string | No | Filters to apply \(e.g. matchFactTypes:76088\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `matchFacts` | array | Array of match fact objects within the date range | +| ↳ `id` | number | Unique id of the match fact | +| ↳ `sport_id` | number | Sport of the match fact | +| ↳ `fixture_id` | number | Fixture related to the match fact | +| ↳ `type_id` | number | Type of the match fact | +| ↳ `participant` | string | Team the fact relates to \(home or away\) | +| ↳ `basis` | string | Basis of the match fact \(e.g. h2h, overall\) | +| ↳ `data` | json | Match fact data payload \(counts and percentages\) | +| ↳ `natural_language` | string | Human-readable description of the match fact | +| ↳ `category` | string | Category of the match fact | +| ↳ `scope` | string | Scope of the match fact | + +### `sportmonks_football_get_match_facts_by_fixture` + +Retrieve match facts for a fixture ID from Sportmonks (beta) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. type;sport;fixture\) | +| `filters` | string | No | Filters to apply \(e.g. matchFactTypes:76088\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `matchFacts` | array | Array of match fact objects for the fixture | +| ↳ `id` | number | Unique id of the match fact | +| ↳ `sport_id` | number | Sport of the match fact | +| ↳ `fixture_id` | number | Fixture related to the match fact | +| ↳ `type_id` | number | Type of the match fact | +| ↳ `participant` | string | Team the fact relates to \(home or away\) | +| ↳ `basis` | string | Basis of the match fact \(e.g. h2h, overall\) | +| ↳ `data` | json | Match fact data payload \(counts and percentages\) | +| ↳ `natural_language` | string | Human-readable description of the match fact | +| ↳ `category` | string | Category of the match fact | +| ↳ `scope` | string | Scope of the match fact | + +### `sportmonks_football_get_match_facts_by_league` + +Retrieve match facts for a league ID from Sportmonks (beta) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `leagueId` | string | Yes | The unique id of the league | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. type;sport;fixture\) | +| `filters` | string | No | Filters to apply \(e.g. matchFactTypes:76088\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `matchFacts` | array | Array of match fact objects for the league | +| ↳ `id` | number | Unique id of the match fact | +| ↳ `sport_id` | number | Sport of the match fact | +| ↳ `fixture_id` | number | Fixture related to the match fact | +| ↳ `type_id` | number | Type of the match fact | +| ↳ `participant` | string | Team the fact relates to \(home or away\) | +| ↳ `basis` | string | Basis of the match fact \(e.g. h2h, overall\) | +| ↳ `data` | json | Match fact data payload \(counts and percentages\) | +| ↳ `natural_language` | string | Human-readable description of the match fact | +| ↳ `category` | string | Category of the match fact | +| ↳ `scope` | string | Scope of the match fact | + +### `sportmonks_football_get_past_fixtures_by_tv_station` + +Retrieve all past fixtures that were available for a TV station ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `tvStationId` | string | Yes | The unique id of the TV station | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of past fixture objects for the TV station | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_player` + +Retrieve a single football player by their ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `playerId` | string | Yes | The unique id of the player | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;position;teams.team;statistics\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `player` | object | The requested player object | +| ↳ `id` | number | Unique id of the player | +| ↳ `sport_id` | number | Sport of the player | +| ↳ `country_id` | number | Country of birth of the player | +| ↳ `nationality_id` | number | Nationality of the player | +| ↳ `city_id` | number | City of birth of the player | +| ↳ `position_id` | number | Position of the player | +| ↳ `detailed_position_id` | number | Detailed position of the player | +| ↳ `type_id` | number | Type of the player | +| ↳ `common_name` | string | Name the player is known for | +| ↳ `firstname` | string | First name of the player | +| ↳ `lastname` | string | Last name of the player | +| ↳ `name` | string | Name of the player | +| ↳ `display_name` | string | Display name of the player | +| ↳ `image_path` | string | URL to the player headshot | +| ↳ `height` | number | Height of the player in cm | +| ↳ `weight` | number | Weight of the player in kg | +| ↳ `date_of_birth` | string | Date of birth of the player | +| ↳ `gender` | string | Gender of the player | + +### `sportmonks_football_get_players_by_country` + +Retrieve all players for a country ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `countryId` | string | Yes | The unique id of the country | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. nationality;position\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order players by id \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `players` | array | Array of player objects for the country | +| ↳ `id` | number | Unique id of the player | +| ↳ `sport_id` | number | Sport of the player | +| ↳ `country_id` | number | Country of birth of the player | +| ↳ `nationality_id` | number | Nationality of the player | +| ↳ `city_id` | number | City of birth of the player | +| ↳ `position_id` | number | Position of the player | +| ↳ `detailed_position_id` | number | Detailed position of the player | +| ↳ `type_id` | number | Type of the player | +| ↳ `common_name` | string | Name the player is known for | +| ↳ `firstname` | string | First name of the player | +| ↳ `lastname` | string | Last name of the player | +| ↳ `name` | string | Name of the player | +| ↳ `display_name` | string | Display name of the player | +| ↳ `image_path` | string | URL to the player headshot | +| ↳ `height` | number | Height of the player in cm | +| ↳ `weight` | number | Weight of the player in kg | +| ↳ `date_of_birth` | string | Date of birth of the player | +| ↳ `gender` | string | Gender of the player | + +### `sportmonks_football_get_postmatch_news` + +Retrieve all post-match news articles available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fixture;league\) | +| `filters` | string | No | Filters to apply \(e.g. newsitemLeagues:8\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order news by id \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `news` | array | Array of post-match news articles | +| ↳ `id` | number | Unique id of the news article | +| ↳ `fixture_id` | number | Fixture related to the news article | +| ↳ `league_id` | number | League related to the news article | +| ↳ `title` | string | Title of the news article | +| ↳ `type` | string | Type of the news \(prematch or postmatch\) | + +### `sportmonks_football_get_postmatch_news_by_season` + +Retrieve all post-match news articles for a season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fixture;league\) | +| `filters` | string | No | Filters to apply \(e.g. newsitemLeagues:8\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order news \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `news` | array | Array of post-match news articles for the season | +| ↳ `id` | number | Unique id of the news article | +| ↳ `fixture_id` | number | Fixture related to the news article | +| ↳ `league_id` | number | League related to the news article | +| ↳ `title` | string | Title of the news article | +| ↳ `type` | string | Type of the news \(prematch or postmatch\) | + +### `sportmonks_football_get_predictability_by_league` + +Retrieve the predictions model performance for a league ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `leagueId` | string | Yes | The unique id of the league | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. type;league\) | +| `filters` | string | No | Filters to apply \(e.g. predictabilityTypes:245\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `predictability` | array | Array of predictability records for the league | +| ↳ `id` | number | Unique id of the predictability record | +| ↳ `league_id` | number | League related to the predictability | +| ↳ `type_id` | number | Type of the predictability | +| ↳ `data` | json | Predictability values per market | + +### `sportmonks_football_get_prematch_news` + +Retrieve all pre-match news articles available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fixture;league\) | +| `filters` | string | No | Filters to apply \(e.g. newsitemLeagues:8\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order news by id \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `news` | array | Array of pre-match news articles | +| ↳ `id` | number | Unique id of the news article | +| ↳ `fixture_id` | number | Fixture related to the news article | +| ↳ `league_id` | number | League related to the news article | +| ↳ `title` | string | Title of the news article | +| ↳ `type` | string | Type of the news \(prematch or postmatch\) | + +### `sportmonks_football_get_prematch_news_by_season` + +Retrieve all pre-match news articles for a season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fixture;league\) | +| `filters` | string | No | Filters to apply \(e.g. newsitemLeagues:8\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order news \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `news` | array | Array of pre-match news articles for the season | +| ↳ `id` | number | Unique id of the news article | +| ↳ `fixture_id` | number | Fixture related to the news article | +| ↳ `league_id` | number | League related to the news article | +| ↳ `title` | string | Title of the news article | +| ↳ `type` | string | Type of the news \(prematch or postmatch\) | + +### `sportmonks_football_get_prematch_news_upcoming` + +Retrieve all pre-match news articles for upcoming fixtures from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fixture;league\) | +| `filters` | string | No | Filters to apply \(e.g. newsitemLeagues:8\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order news \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `news` | array | Array of pre-match news articles for upcoming fixtures | +| ↳ `id` | number | Unique id of the news article | +| ↳ `fixture_id` | number | Fixture related to the news article | +| ↳ `league_id` | number | League related to the news article | +| ↳ `title` | string | Title of the news article | +| ↳ `type` | string | Type of the news \(prematch or postmatch\) | + +### `sportmonks_football_get_probabilities` + +Retrieve all prediction probabilities available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. type;fixture\) | +| `filters` | string | No | Filters to apply \(e.g. predictionTypes:236\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `predictions` | array | Array of prediction probability objects | +| ↳ `id` | number | Unique id of the prediction | +| ↳ `fixture_id` | number | Fixture related to the prediction | +| ↳ `predictions` | json | Prediction payload \(varies by type: score map, value bet object, etc.\) | +| ↳ `type_id` | number | Type of the prediction | + +### `sportmonks_football_get_probabilities_by_fixture` + +Retrieve prediction probabilities for a fixture by fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. type;fixture\) | +| `filters` | string | No | Filters to apply \(e.g. predictionTypes:236\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `predictions` | array | Array of prediction probability entries for the fixture | +| ↳ `id` | number | Unique id of the prediction | +| ↳ `fixture_id` | number | Fixture related to the prediction | +| ↳ `predictions` | json | Prediction payload \(varies by type: score map, value bet object, etc.\) | +| ↳ `type_id` | number | Type of the prediction | + +### `sportmonks_football_get_referee` + +Retrieve a single football referee by their ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `refereeId` | string | Yes | The unique id of the referee | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;statistics\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `referee` | object | The requested referee object | +| ↳ `id` | number | Unique id of the referee | +| ↳ `sport_id` | number | Sport of the referee | +| ↳ `country_id` | number | Country of the referee | +| ↳ `nationality_id` | number | Nationality of the referee | +| ↳ `city_id` | number | Birth city of the referee | +| ↳ `common_name` | string | Common name of the referee | +| ↳ `firstname` | string | First name of the referee | +| ↳ `lastname` | string | Last name of the referee | +| ↳ `name` | string | Name of the referee | +| ↳ `display_name` | string | Display name of the referee | +| ↳ `image_path` | string | URL to the referee headshot | +| ↳ `height` | number | Height of the referee in cm | +| ↳ `weight` | number | Weight of the referee in kg | +| ↳ `date_of_birth` | string | Date of birth of the referee | +| ↳ `gender` | string | Gender of the referee | + +### `sportmonks_football_get_referees` + +Retrieve all football referees available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;statistics\) | +| `filters` | string | No | Filters to apply \(e.g. refereeCountries:44\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `referees` | array | Array of referee objects | +| ↳ `id` | number | Unique id of the referee | +| ↳ `sport_id` | number | Sport of the referee | +| ↳ `country_id` | number | Country of the referee | +| ↳ `nationality_id` | number | Nationality of the referee | +| ↳ `city_id` | number | Birth city of the referee | +| ↳ `common_name` | string | Common name of the referee | +| ↳ `firstname` | string | First name of the referee | +| ↳ `lastname` | string | Last name of the referee | +| ↳ `name` | string | Name of the referee | +| ↳ `display_name` | string | Display name of the referee | +| ↳ `image_path` | string | URL to the referee headshot | +| ↳ `height` | number | Height of the referee in cm | +| ↳ `weight` | number | Weight of the referee in kg | +| ↳ `date_of_birth` | string | Date of birth of the referee | +| ↳ `gender` | string | Gender of the referee | + +### `sportmonks_football_get_referees_by_country` + +Retrieve all referees for a country ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `countryId` | string | Yes | The unique id of the country | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;nationality\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `referees` | array | Array of referee objects for the country | +| ↳ `id` | number | Unique id of the referee | +| ↳ `sport_id` | number | Sport of the referee | +| ↳ `country_id` | number | Country of the referee | +| ↳ `nationality_id` | number | Nationality of the referee | +| ↳ `city_id` | number | Birth city of the referee | +| ↳ `common_name` | string | Common name of the referee | +| ↳ `firstname` | string | First name of the referee | +| ↳ `lastname` | string | Last name of the referee | +| ↳ `name` | string | Name of the referee | +| ↳ `display_name` | string | Display name of the referee | +| ↳ `image_path` | string | URL to the referee headshot | +| ↳ `height` | number | Height of the referee in cm | +| ↳ `weight` | number | Weight of the referee in kg | +| ↳ `date_of_birth` | string | Date of birth of the referee | +| ↳ `gender` | string | Gender of the referee | + +### `sportmonks_football_get_referees_by_season` + +Retrieve all referees for a season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;nationality\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `referees` | array | Array of referee objects for the season | +| ↳ `id` | number | Unique id of the referee | +| ↳ `sport_id` | number | Sport of the referee | +| ↳ `country_id` | number | Country of the referee | +| ↳ `nationality_id` | number | Nationality of the referee | +| ↳ `city_id` | number | Birth city of the referee | +| ↳ `common_name` | string | Common name of the referee | +| ↳ `firstname` | string | First name of the referee | +| ↳ `lastname` | string | Last name of the referee | +| ↳ `name` | string | Name of the referee | +| ↳ `display_name` | string | Display name of the referee | +| ↳ `image_path` | string | URL to the referee headshot | +| ↳ `height` | number | Height of the referee in cm | +| ↳ `weight` | number | Weight of the referee in kg | +| ↳ `date_of_birth` | string | Date of birth of the referee | +| ↳ `gender` | string | Gender of the referee | + +### `sportmonks_football_get_rivals_by_team` + +Retrieve rival teams for a team by team ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. team;rival\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rivals` | array | Array of rival relationships for the team | +| ↳ `sport_id` | number | Sport of the rival | +| ↳ `team_id` | number | Team the rivalry belongs to | +| ↳ `rival_id` | number | Rival team id | + +### `sportmonks_football_get_round` + +Retrieve a single football round by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `roundId` | string | Yes | The unique id of the round | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;season;stage;fixtures\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `round` | object | The requested round object | +| ↳ `id` | number | Unique id of the round | +| ↳ `sport_id` | number | Sport of the round | +| ↳ `league_id` | number | League of the round | +| ↳ `season_id` | number | Season of the round | +| ↳ `stage_id` | number | Stage of the round | +| ↳ `name` | string | Name of the round | +| ↳ `finished` | boolean | Whether the round is finished | +| ↳ `is_current` | boolean | Whether the round is the current round | +| ↳ `starting_at` | string | Start date of the round | +| ↳ `ending_at` | string | End date of the round | +| ↳ `games_in_current_week` | boolean | Whether the round has fixtures this week | + +### `sportmonks_football_get_round_statistics` + +Retrieve all available statistics for a round ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `roundId` | string | Yes | The unique id of the round | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant\) | +| `filters` | string | No | Filters to apply \(e.g. seasonstatisticTypes:52,88\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `statistics` | array | Array of statistic entries for the round | +| ↳ `id` | number | Unique id of the statistic record | +| ↳ `model_id` | number | Id of the entity the statistic belongs to | +| ↳ `type_id` | number | Type of the statistic | +| ↳ `relation_id` | number | Related entity id \(e.g. participant\) when applicable | +| ↳ `value` | json | Statistic value payload \(varies by type\) | + +### `sportmonks_football_get_rounds` + +Retrieve all football rounds available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;season;stage\) | +| `filters` | string | No | Filters to apply \(e.g. roundSeasons:19735\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rounds` | array | Array of round objects | +| ↳ `id` | number | Unique id of the round | +| ↳ `sport_id` | number | Sport of the round | +| ↳ `league_id` | number | League of the round | +| ↳ `season_id` | number | Season of the round | +| ↳ `stage_id` | number | Stage of the round | +| ↳ `name` | string | Name of the round | +| ↳ `finished` | boolean | Whether the round is finished | +| ↳ `is_current` | boolean | Whether the round is the current round | +| ↳ `starting_at` | string | Start date of the round | +| ↳ `ending_at` | string | End date of the round | +| ↳ `games_in_current_week` | boolean | Whether the round has fixtures this week | + +### `sportmonks_football_get_rounds_by_season` + +Retrieve all rounds for a season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;stage\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rounds` | array | Array of round objects for the season | +| ↳ `id` | number | Unique id of the round | +| ↳ `sport_id` | number | Sport of the round | +| ↳ `league_id` | number | League of the round | +| ↳ `season_id` | number | Season of the round | +| ↳ `stage_id` | number | Stage of the round | +| ↳ `name` | string | Name of the round | +| ↳ `finished` | boolean | Whether the round is finished | +| ↳ `is_current` | boolean | Whether the round is the current round | +| ↳ `starting_at` | string | Start date of the round | +| ↳ `ending_at` | string | End date of the round | +| ↳ `games_in_current_week` | boolean | Whether the round has fixtures this week | + +### `sportmonks_football_get_schedules_by_season` + +Retrieve the full schedule (stages, rounds and fixtures) for a season by season ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `schedules` | json | Array of stages, each with nested rounds and their fixtures \(participants, scores\) | + +### `sportmonks_football_get_schedules_by_season_and_team` + +Retrieve the full season schedule for a specific team by season ID and team ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `teamId` | string | Yes | The unique id of the team | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `schedules` | json | Array of stages, each with nested rounds and their fixtures for the team in the season | + +### `sportmonks_football_get_schedules_by_team` + +Retrieve the full schedule (stages, rounds and fixtures) for a team by team ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `schedules` | json | Array of stages, each with nested rounds and their fixtures \(participants, scores\) | + +### `sportmonks_football_get_season` + +Retrieve a single football season by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;stages;fixtures\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `season` | object | The requested season object | +| ↳ `id` | number | Unique id of the season | +| ↳ `sport_id` | number | Sport of the season | +| ↳ `league_id` | number | League of the season | +| ↳ `tie_breaker_rule_id` | number | Tie-breaker rule of the season | +| ↳ `name` | string | Name of the season \(e.g. 2023/2024\) | +| ↳ `finished` | boolean | Whether the season is finished | +| ↳ `pending` | boolean | Whether the season is pending | +| ↳ `is_current` | boolean | Whether the season is the current season | +| ↳ `standing_method` | string | Standing calculation method | +| ↳ `starting_at` | string | Start date of the season | +| ↳ `ending_at` | string | End date of the season | +| ↳ `standings_recalculated_at` | string | Last standings recalculation time | +| ↳ `games_in_current_week` | boolean | Whether the season has fixtures this week | + +### `sportmonks_football_get_seasons` + +Retrieve all football seasons available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;stages\) | +| `filters` | string | No | Filters to apply \(e.g. seasonLeagues:501\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `seasons` | array | Array of season objects | +| ↳ `id` | number | Unique id of the season | +| ↳ `sport_id` | number | Sport of the season | +| ↳ `league_id` | number | League of the season | +| ↳ `tie_breaker_rule_id` | number | Tie-breaker rule of the season | +| ↳ `name` | string | Name of the season \(e.g. 2023/2024\) | +| ↳ `finished` | boolean | Whether the season is finished | +| ↳ `pending` | boolean | Whether the season is pending | +| ↳ `is_current` | boolean | Whether the season is the current season | +| ↳ `standing_method` | string | Standing calculation method | +| ↳ `starting_at` | string | Start date of the season | +| ↳ `ending_at` | string | End date of the season | +| ↳ `standings_recalculated_at` | string | Last standings recalculation time | +| ↳ `games_in_current_week` | boolean | Whether the season has fixtures this week | + +### `sportmonks_football_get_seasons_by_team` + +Retrieve all seasons for a team ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;stages\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `seasons` | array | Array of season objects for the team | +| ↳ `id` | number | Unique id of the season | +| ↳ `sport_id` | number | Sport of the season | +| ↳ `league_id` | number | League of the season | +| ↳ `tie_breaker_rule_id` | number | Tie-breaker rule of the season | +| ↳ `name` | string | Name of the season \(e.g. 2023/2024\) | +| ↳ `finished` | boolean | Whether the season is finished | +| ↳ `pending` | boolean | Whether the season is pending | +| ↳ `is_current` | boolean | Whether the season is the current season | +| ↳ `standing_method` | string | Standing calculation method | +| ↳ `starting_at` | string | Start date of the season | +| ↳ `ending_at` | string | End date of the season | +| ↳ `standings_recalculated_at` | string | Last standings recalculation time | +| ↳ `games_in_current_week` | boolean | Whether the season has fixtures this week | + +### `sportmonks_football_get_stage` + +Retrieve a single football stage by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `stageId` | string | Yes | The unique id of the stage | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;season;rounds\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stage` | object | The requested stage object | +| ↳ `id` | number | Unique id of the stage | +| ↳ `sport_id` | number | Sport of the stage | +| ↳ `league_id` | number | League of the stage | +| ↳ `season_id` | number | Season of the stage | +| ↳ `type_id` | number | Type of the stage | +| ↳ `name` | string | Name of the stage | +| ↳ `sort_order` | number | Sort order of the stage | +| ↳ `finished` | boolean | Whether the stage is finished | +| ↳ `is_current` | boolean | Whether the stage is the current stage | +| ↳ `starting_at` | string | Start date of the stage | +| ↳ `ending_at` | string | End date of the stage | +| ↳ `games_in_current_week` | boolean | Whether the stage has fixtures this week | + +### `sportmonks_football_get_stage_statistics` + +Retrieve all available statistics for a stage ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `stageId` | string | Yes | The unique id of the stage | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant\) | +| `filters` | string | No | Filters to apply \(e.g. seasonstatisticTypes:52,88\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `statistics` | array | Array of statistic entries for the stage | +| ↳ `id` | number | Unique id of the statistic record | +| ↳ `model_id` | number | Id of the entity the statistic belongs to | +| ↳ `type_id` | number | Type of the statistic | +| ↳ `relation_id` | number | Related entity id \(e.g. participant\) when applicable | +| ↳ `value` | json | Statistic value payload \(varies by type\) | + +### `sportmonks_football_get_stages` + +Retrieve all football stages available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;season;rounds\) | +| `filters` | string | No | Filters to apply \(e.g. stageSeasons:19735\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stages` | array | Array of stage objects | +| ↳ `id` | number | Unique id of the stage | +| ↳ `sport_id` | number | Sport of the stage | +| ↳ `league_id` | number | League of the stage | +| ↳ `season_id` | number | Season of the stage | +| ↳ `type_id` | number | Type of the stage | +| ↳ `name` | string | Name of the stage | +| ↳ `sort_order` | number | Sort order of the stage | +| ↳ `finished` | boolean | Whether the stage is finished | +| ↳ `is_current` | boolean | Whether the stage is the current stage | +| ↳ `starting_at` | string | Start date of the stage | +| ↳ `ending_at` | string | End date of the stage | +| ↳ `games_in_current_week` | boolean | Whether the stage has fixtures this week | + +### `sportmonks_football_get_stages_by_season` + +Retrieve all stages for a season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;rounds\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stages` | array | Array of stage objects for the season | +| ↳ `id` | number | Unique id of the stage | +| ↳ `sport_id` | number | Sport of the stage | +| ↳ `league_id` | number | League of the stage | +| ↳ `season_id` | number | Season of the stage | +| ↳ `type_id` | number | Type of the stage | +| ↳ `name` | string | Name of the stage | +| ↳ `sort_order` | number | Sort order of the stage | +| ↳ `finished` | boolean | Whether the stage is finished | +| ↳ `is_current` | boolean | Whether the stage is the current stage | +| ↳ `starting_at` | string | Start date of the stage | +| ↳ `ending_at` | string | End date of the stage | +| ↳ `games_in_current_week` | boolean | Whether the stage has fixtures this week | + +### `sportmonks_football_get_standing_corrections_by_season` + +Retrieve point corrections (awarded or deducted) for a season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;stage\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `corrections` | array | Array of standing correction entries for the season | +| ↳ `id` | number | Unique id of the standing correction | +| ↳ `season_id` | number | Season related to the correction | +| ↳ `stage_id` | number | Stage related to the correction | +| ↳ `group_id` | number | Group related to the correction | +| ↳ `type_id` | number | Type of the correction | +| ↳ `value` | number | Amount of points awarded or deducted | +| ↳ `calc_type` | string | Calculation type applied \(e.g. + or -\) | +| ↳ `participant_type` | string | Type of the participant \(e.g. team\) | +| ↳ `participant_id` | number | Participant the correction applies to | +| ↳ `active` | boolean | Whether the correction is active | + +### `sportmonks_football_get_standings` + +Retrieve all standings available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;league;season\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `standings` | array | Array of standing entries | +| ↳ `id` | number | Unique id of the standing | +| ↳ `participant_id` | number | Team related to the standing | +| ↳ `sport_id` | number | Sport related to the standing | +| ↳ `league_id` | number | League related to the standing | +| ↳ `season_id` | number | Season related to the standing | +| ↳ `stage_id` | number | Stage related to the standing | +| ↳ `group_id` | number | Group related to the standing | +| ↳ `round_id` | number | Round related to the standing | +| ↳ `standing_rule_id` | number | Standing rule related to the standing | +| ↳ `position` | number | Position of the team in the standing | +| ↳ `result` | string | Movement of the team in the standing | +| ↳ `points` | number | Points the team has gathered | + +### `sportmonks_football_get_standings_by_round` + +Retrieve the full standing table for a round ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `roundId` | string | Yes | The unique id of the round | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply \(e.g. standingGroups:246697\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `standings` | array | Array of standing entries for the round | +| ↳ `id` | number | Unique id of the standing | +| ↳ `participant_id` | number | Team related to the standing | +| ↳ `sport_id` | number | Sport related to the standing | +| ↳ `league_id` | number | League related to the standing | +| ↳ `season_id` | number | Season related to the standing | +| ↳ `stage_id` | number | Stage related to the standing | +| ↳ `group_id` | number | Group related to the standing | +| ↳ `round_id` | number | Round related to the standing | +| ↳ `standing_rule_id` | number | Standing rule related to the standing | +| ↳ `position` | number | Position of the team in the standing | +| ↳ `result` | string | Movement of the team in the standing | +| ↳ `points` | number | Points the team has gathered | + +### `sportmonks_football_get_standings_by_season` + +Retrieve the full league standings table for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details;form\) | +| `filters` | string | No | Filters to apply \(e.g. standingStages:77453568\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `standings` | array | Array of standing entries for the season | +| ↳ `id` | number | Unique id of the standing | +| ↳ `participant_id` | number | Team related to the standing | +| ↳ `sport_id` | number | Sport related to the standing | +| ↳ `league_id` | number | League related to the standing | +| ↳ `season_id` | number | Season related to the standing | +| ↳ `stage_id` | number | Stage related to the standing | +| ↳ `group_id` | number | Group related to the standing | +| ↳ `round_id` | number | Round related to the standing | +| ↳ `standing_rule_id` | number | Standing rule related to the standing | +| ↳ `position` | number | Position of the team in the standing | +| ↳ `result` | string | Movement of the team in the standing | +| ↳ `points` | number | Points the team has gathered | + +### `sportmonks_football_get_state` + +Retrieve a single fixture state by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `stateId` | string | Yes | The unique id of the state | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `state` | object | The requested fixture state object | +| ↳ `id` | number | Unique id of the state | +| ↳ `state` | string | State code \(e.g. NS, INPLAY_1ST_HALF\) | +| ↳ `name` | string | Full name of the state \(e.g. Not Started\) | +| ↳ `short_name` | string | Short name of the state \(e.g. NS\) | +| ↳ `developer_name` | string | Developer name of the state | + +### `sportmonks_football_get_states` + +Retrieve all fixture states (e.g. Not Started, 1st Half, Full Time) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `states` | array | Array of fixture state objects | +| ↳ `id` | number | Unique id of the state | +| ↳ `state` | string | State code \(e.g. NS, INPLAY_1ST_HALF\) | +| ↳ `name` | string | Full name of the state \(e.g. Not Started\) | +| ↳ `short_name` | string | Short name of the state \(e.g. NS\) | +| ↳ `developer_name` | string | Developer name of the state | + +### `sportmonks_football_get_team` + +Retrieve a single football team by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;venue;coaches;players.player\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `team` | object | The requested team object | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Home venue of the team | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the last played match | + +### `sportmonks_football_get_team_rankings` + +Retrieve all team rankings available within your Sportmonks subscription (beta) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. team\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teamRankings` | array | Array of team ranking objects | +| ↳ `id` | number | Unique id of the team ranking | +| ↳ `team_id` | number | Team related to the ranking | +| ↳ `date` | string | Date of the ranking | +| ↳ `current_rank` | number | Placement of the team on that date | +| ↳ `scaled_score` | number | Scaled score of the team \(0-100\) | + +### `sportmonks_football_get_team_rankings_by_date` + +Retrieve team rankings for a given date (YYYY-MM-DD) from Sportmonks (beta) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `date` | string | Yes | The ranking date in YYYY-MM-DD format | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. team\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teamRankings` | array | Array of team ranking objects for the date | +| ↳ `id` | number | Unique id of the team ranking | +| ↳ `team_id` | number | Team related to the ranking | +| ↳ `date` | string | Date of the ranking | +| ↳ `current_rank` | number | Placement of the team on that date | +| ↳ `scaled_score` | number | Scaled score of the team \(0-100\) | + +### `sportmonks_football_get_team_rankings_by_team` + +Retrieve team rankings for a team ID from Sportmonks (beta) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. team\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teamRankings` | array | Array of team ranking objects for the team | +| ↳ `id` | number | Unique id of the team ranking | +| ↳ `team_id` | number | Team related to the ranking | +| ↳ `date` | string | Date of the ranking | +| ↳ `current_rank` | number | Placement of the team on that date | +| ↳ `scaled_score` | number | Scaled score of the team \(0-100\) | + +### `sportmonks_football_get_team_squad` + +Retrieve the current domestic squad for a team by team ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;position\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `squad` | array | Array of squad entries for the team | +| ↳ `id` | number | Unique id of the squad record | +| ↳ `transfer_id` | number | Transfer id of the squad record | +| ↳ `player_id` | number | Player in the squad | +| ↳ `team_id` | number | Team of the squad | +| ↳ `position_id` | number | Position of the player in the squad | +| ↳ `detailed_position_id` | number | Detailed position of the player in the squad | +| ↳ `jersey_number` | number | Jersey number of the player | +| ↳ `start` | string | Start contract date of the player | +| ↳ `end` | string | End contract date of the player | + +### `sportmonks_football_get_team_squad_by_season` + +Retrieve the (historical) squad for a team in a specific season from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;position\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `squad` | array | Array of squad entries for the team in the season | +| ↳ `id` | number | Unique id of the squad record | +| ↳ `transfer_id` | number | Transfer id of the squad record | +| ↳ `player_id` | number | Player in the squad | +| ↳ `team_id` | number | Team of the squad | +| ↳ `position_id` | number | Position of the player in the squad | +| ↳ `detailed_position_id` | number | Detailed position of the player in the squad | +| ↳ `jersey_number` | number | Jersey number of the player | +| ↳ `start` | string | Start contract date of the player | +| ↳ `end` | string | End contract date of the player | + +### `sportmonks_football_get_teams_by_country` + +Retrieve all teams for a country ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `countryId` | string | Yes | The unique id of the country | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;venue\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order teams by id \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teams` | array | Array of team objects for the country | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Home venue of the team | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the last played match | + +### `sportmonks_football_get_teams_by_season` + +Retrieve all teams for a season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;venue\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order teams by id \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teams` | array | Array of team objects for the season | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Home venue of the team | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the last played match | + +### `sportmonks_football_get_topscorers_by_season` + +Retrieve the topscorers (goals, assists, cards) for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;participant;type\) | +| `filters` | string | No | Filters to apply \(e.g. seasontopscorerTypes:208\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order topscorers by position \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `topscorers` | array | Array of topscorer entries for the season | +| ↳ `id` | number | Unique id of the topscorer record | +| ↳ `season_id` | number | Season related to the topscorer \(absent on stage topscorers\) | +| ↳ `league_id` | number | League related to the topscorer | +| ↳ `stage_id` | number | Stage related to the topscorer | +| ↳ `player_id` | number | Player related to the topscorer | +| ↳ `participant_id` | number | Team related to the topscorer | +| ↳ `type_id` | number | Type of the topscorer \(goals, assists, cards\) | +| ↳ `position` | number | Position of the topscorer | +| ↳ `total` | number | Number of goals, assists or cards | + +### `sportmonks_football_get_topscorers_by_stage` + +Retrieve topscorers for a stage by stage ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `stageId` | string | Yes | The unique id of the stage | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;participant;type\) | +| `filters` | string | No | Filters to apply \(e.g. stageTopscorerTypes:208\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `topscorers` | array | Array of topscorer entries for the stage | +| ↳ `id` | number | Unique id of the topscorer record | +| ↳ `season_id` | number | Season related to the topscorer \(absent on stage topscorers\) | +| ↳ `league_id` | number | League related to the topscorer | +| ↳ `stage_id` | number | Stage related to the topscorer | +| ↳ `player_id` | number | Player related to the topscorer | +| ↳ `participant_id` | number | Team related to the topscorer | +| ↳ `type_id` | number | Type of the topscorer \(goals, assists, cards\) | +| ↳ `position` | number | Position of the topscorer | +| ↳ `total` | number | Number of goals, assists or cards | + +### `sportmonks_football_get_totw` + +Retrieve all available Team of the Week (TOTW) entries from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fixture;team;player;round\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totw` | array | Array of Team of the Week entries | +| ↳ `id` | number | Unique id of the TOTW entry | +| ↳ `player_id` | number | Player of the team of the week | +| ↳ `fixture_id` | number | Fixture the TOTW player played in | +| ↳ `round_id` | number | Round the fixture is played at | +| ↳ `team_id` | number | Team the TOTW player played for | +| ↳ `rating` | string | Rating of the TOTW player | +| ↳ `formation_position` | number | Player position in the TOTW formation | +| ↳ `formation` | string | The TOTW's formation | + +### `sportmonks_football_get_totw_by_round` + +Retrieve the Team of the Week (TOTW) for a round ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `roundId` | string | Yes | The unique id of the round | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fixture;team;player;round\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totw` | array | Array of Team of the Week entries for the round | +| ↳ `id` | number | Unique id of the TOTW entry | +| ↳ `player_id` | number | Player of the team of the week | +| ↳ `fixture_id` | number | Fixture the TOTW player played in | +| ↳ `round_id` | number | Round the fixture is played at | +| ↳ `team_id` | number | Team the TOTW player played for | +| ↳ `rating` | string | Rating of the TOTW player | +| ↳ `formation_position` | number | Player position in the TOTW formation | +| ↳ `formation` | string | The TOTW's formation | + +### `sportmonks_football_get_transfer` + +Retrieve a single transfer by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `transferId` | string | Yes | The unique id of the transfer | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;fromTeam;toTeam\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transfer` | object | The requested transfer object | +| ↳ `id` | number | Unique id of the transfer | +| ↳ `sport_id` | number | Sport of the transfer | +| ↳ `player_id` | number | Player who transferred | +| ↳ `type_id` | number | Type of the transfer | +| ↳ `from_team_id` | number | Team the player transferred from | +| ↳ `to_team_id` | number | Team the player transferred to | +| ↳ `position_id` | number | Position id of the transfer | +| ↳ `detailed_position_id` | number | Detailed position id of the transfer | +| ↳ `date` | string | Date of the transfer | +| ↳ `career_ended` | boolean | Whether the transfer ended the career | +| ↳ `completed` | boolean | Whether the transfer is completed | +| ↳ `amount` | number | Transfer fee amount | + +### `sportmonks_football_get_transfer_rumour` + +Retrieve a single transfer rumour by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `rumourId` | string | Yes | The unique id of the transfer rumour | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;fromTeam;toTeam\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transferRumour` | object | The requested transfer rumour object | +| ↳ `id` | number | Unique id of the transfer rumour | +| ↳ `sport_id` | number | Sport of the transfer rumour | +| ↳ `player_id` | number | Player the rumour relates to | +| ↳ `position_id` | number | Position id of the player | +| ↳ `from_team_id` | number | Team the player would transfer from | +| ↳ `to_team_id` | number | Team the player would transfer to | +| ↳ `transfer_fee_id` | number | Transfer fee id of the rumour | +| ↳ `probability` | string | Probability of the rumour \(e.g. LOW\) | +| ↳ `source_name` | string | Name of the source of the rumour | +| ↳ `source_url` | string | URL of the source of the rumour | +| ↳ `amount` | number | Estimated transfer fee amount | +| ↳ `currency` | string | Currency of the amount | +| ↳ `date` | string | Date of the rumour | +| ↳ `type_id` | number | Type of the transfer rumour | + +### `sportmonks_football_get_transfer_rumours_between_dates` + +Retrieve transfer rumours within a date range (YYYY-MM-DD) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `startDate` | string | Yes | Start date in YYYY-MM-DD format | +| `endDate` | string | Yes | End date in YYYY-MM-DD format | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;fromTeam;toTeam\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transferRumours` | array | Array of transfer rumour objects within the date range | +| ↳ `id` | number | Unique id of the transfer rumour | +| ↳ `sport_id` | number | Sport of the transfer rumour | +| ↳ `player_id` | number | Player the rumour relates to | +| ↳ `position_id` | number | Position id of the player | +| ↳ `from_team_id` | number | Team the player would transfer from | +| ↳ `to_team_id` | number | Team the player would transfer to | +| ↳ `transfer_fee_id` | number | Transfer fee id of the rumour | +| ↳ `probability` | string | Probability of the rumour \(e.g. LOW\) | +| ↳ `source_name` | string | Name of the source of the rumour | +| ↳ `source_url` | string | URL of the source of the rumour | +| ↳ `amount` | number | Estimated transfer fee amount | +| ↳ `currency` | string | Currency of the amount | +| ↳ `date` | string | Date of the rumour | +| ↳ `type_id` | number | Type of the transfer rumour | + +### `sportmonks_football_get_transfer_rumours_by_player` + +Retrieve transfer rumours for a player ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `playerId` | string | Yes | The unique id of the player | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;fromTeam;toTeam\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transferRumours` | array | Array of transfer rumour objects for the player | +| ↳ `id` | number | Unique id of the transfer rumour | +| ↳ `sport_id` | number | Sport of the transfer rumour | +| ↳ `player_id` | number | Player the rumour relates to | +| ↳ `position_id` | number | Position id of the player | +| ↳ `from_team_id` | number | Team the player would transfer from | +| ↳ `to_team_id` | number | Team the player would transfer to | +| ↳ `transfer_fee_id` | number | Transfer fee id of the rumour | +| ↳ `probability` | string | Probability of the rumour \(e.g. LOW\) | +| ↳ `source_name` | string | Name of the source of the rumour | +| ↳ `source_url` | string | URL of the source of the rumour | +| ↳ `amount` | number | Estimated transfer fee amount | +| ↳ `currency` | string | Currency of the amount | +| ↳ `date` | string | Date of the rumour | +| ↳ `type_id` | number | Type of the transfer rumour | + +### `sportmonks_football_get_transfer_rumours_by_team` + +Retrieve transfer rumours for a team ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;fromTeam;toTeam\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transferRumours` | array | Array of transfer rumour objects for the team | +| ↳ `id` | number | Unique id of the transfer rumour | +| ↳ `sport_id` | number | Sport of the transfer rumour | +| ↳ `player_id` | number | Player the rumour relates to | +| ↳ `position_id` | number | Position id of the player | +| ↳ `from_team_id` | number | Team the player would transfer from | +| ↳ `to_team_id` | number | Team the player would transfer to | +| ↳ `transfer_fee_id` | number | Transfer fee id of the rumour | +| ↳ `probability` | string | Probability of the rumour \(e.g. LOW\) | +| ↳ `source_name` | string | Name of the source of the rumour | +| ↳ `source_url` | string | URL of the source of the rumour | +| ↳ `amount` | number | Estimated transfer fee amount | +| ↳ `currency` | string | Currency of the amount | +| ↳ `date` | string | Date of the rumour | +| ↳ `type_id` | number | Type of the transfer rumour | + +### `sportmonks_football_get_transfers_between_dates` + +Retrieve transfers within a date range (YYYY-MM-DD) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `startDate` | string | Yes | Start date in YYYY-MM-DD format | +| `endDate` | string | Yes | End date in YYYY-MM-DD format | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;fromTeam;toTeam\) | +| `filters` | string | No | Filters to apply \(e.g. transferTypes:219,220\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transfers` | array | Array of transfer objects within the date range | +| ↳ `id` | number | Unique id of the transfer | +| ↳ `sport_id` | number | Sport of the transfer | +| ↳ `player_id` | number | Player who transferred | +| ↳ `type_id` | number | Type of the transfer | +| ↳ `from_team_id` | number | Team the player transferred from | +| ↳ `to_team_id` | number | Team the player transferred to | +| ↳ `position_id` | number | Position id of the transfer | +| ↳ `detailed_position_id` | number | Detailed position id of the transfer | +| ↳ `date` | string | Date of the transfer | +| ↳ `career_ended` | boolean | Whether the transfer ended the career | +| ↳ `completed` | boolean | Whether the transfer is completed | +| ↳ `amount` | number | Transfer fee amount | + +### `sportmonks_football_get_transfers_by_player` + +Retrieve transfers for a player by player ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `playerId` | string | Yes | The unique id of the player | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fromTeam;toTeam;type\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transfers` | array | Array of transfer objects for the player | +| ↳ `id` | number | Unique id of the transfer | +| ↳ `sport_id` | number | Sport of the transfer | +| ↳ `player_id` | number | Player who transferred | +| ↳ `type_id` | number | Type of the transfer | +| ↳ `from_team_id` | number | Team the player transferred from | +| ↳ `to_team_id` | number | Team the player transferred to | +| ↳ `position_id` | number | Position id of the transfer | +| ↳ `detailed_position_id` | number | Detailed position id of the transfer | +| ↳ `date` | string | Date of the transfer | +| ↳ `career_ended` | boolean | Whether the transfer ended the career | +| ↳ `completed` | boolean | Whether the transfer is completed | +| ↳ `amount` | number | Transfer fee amount | + +### `sportmonks_football_get_transfers_by_team` + +Retrieve transfers for a team by team ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. player;fromTeam;toTeam\) | +| `filters` | string | No | Filters to apply \(e.g. transferTypes:219,220\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transfers` | array | Array of transfer objects for the team | +| ↳ `id` | number | Unique id of the transfer | +| ↳ `sport_id` | number | Sport of the transfer | +| ↳ `player_id` | number | Player who transferred | +| ↳ `type_id` | number | Type of the transfer | +| ↳ `from_team_id` | number | Team the player transferred from | +| ↳ `to_team_id` | number | Team the player transferred to | +| ↳ `position_id` | number | Position id of the transfer | +| ↳ `detailed_position_id` | number | Detailed position id of the transfer | +| ↳ `date` | string | Date of the transfer | +| ↳ `career_ended` | boolean | Whether the transfer ended the career | +| ↳ `completed` | boolean | Whether the transfer is completed | +| ↳ `amount` | number | Transfer fee amount | + +### `sportmonks_football_get_tv_station` + +Retrieve a single TV station by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `tvStationId` | string | Yes | The unique id of the TV station | +| `include` | string | No | Semicolon-separated relations to enrich the response | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tvStation` | object | The requested TV station object | +| ↳ `id` | number | Unique id of the TV station | +| ↳ `name` | string | Name of the TV station | +| ↳ `url` | string | URL of the TV station | +| ↳ `image_path` | string | Image path of the TV station | +| ↳ `type` | string | Type of the TV station \(tv, channel\) | +| ↳ `related_id` | number | Related id of the TV station | + +### `sportmonks_football_get_tv_stations` + +Retrieve all TV stations available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tvStations` | array | Array of TV station objects | +| ↳ `id` | number | Unique id of the TV station | +| ↳ `name` | string | Name of the TV station | +| ↳ `url` | string | URL of the TV station | +| ↳ `image_path` | string | Image path of the TV station | +| ↳ `type` | string | Type of the TV station \(tv, channel\) | +| ↳ `related_id` | number | Related id of the TV station | + +### `sportmonks_football_get_tv_stations_by_fixture` + +Retrieve broadcasting TV stations for a fixture by fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. fixtures;countries\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tvStations` | array | Array of TV station objects broadcasting the fixture | +| ↳ `id` | number | Unique id of the TV station | +| ↳ `name` | string | Name of the TV station | +| ↳ `url` | string | URL of the TV station | +| ↳ `image_path` | string | Image path of the TV station | +| ↳ `type` | string | Type of the TV station \(tv, channel\) | +| ↳ `related_id` | number | Related id of the TV station | + +### `sportmonks_football_get_upcoming_fixtures_by_market` + +Retrieve all upcoming fixtures for a market ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `marketId` | string | Yes | The unique id of the market | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;odds\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of upcoming fixture objects for the market | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_upcoming_fixtures_by_tv_station` + +Retrieve all upcoming fixtures available for a TV station ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `tvStationId` | string | Yes | The unique id of the TV station | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of upcoming fixture objects for the TV station | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_get_value_bets` + +Retrieve all value bets available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. type;fixture\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `valueBets` | array | Array of value bet prediction objects | +| ↳ `id` | number | Unique id of the prediction | +| ↳ `fixture_id` | number | Fixture related to the prediction | +| ↳ `predictions` | json | Prediction payload \(varies by type: score map, value bet object, etc.\) | +| ↳ `type_id` | number | Type of the prediction | + +### `sportmonks_football_get_value_bets_by_fixture` + +Retrieve value bet predictions for a fixture by fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. type;fixture\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `valueBets` | array | Array of value bet prediction entries for the fixture | +| ↳ `id` | number | Unique id of the prediction | +| ↳ `fixture_id` | number | Fixture related to the prediction | +| ↳ `predictions` | json | Prediction payload \(varies by type: score map, value bet object, etc.\) | +| ↳ `type_id` | number | Type of the prediction | + +### `sportmonks_football_get_venue` + +Retrieve a single football venue by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `venueId` | string | Yes | The unique id of the venue | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;city;fixtures\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `venue` | object | The requested venue object | +| ↳ `id` | number | Unique id of the venue | +| ↳ `country_id` | number | Country of the venue | +| ↳ `city_id` | number | City of the venue | +| ↳ `name` | string | Name of the venue | +| ↳ `address` | string | Address of the venue | +| ↳ `zipcode` | string | Zipcode of the venue | +| ↳ `latitude` | string | Latitude of the venue | +| ↳ `longitude` | string | Longitude of the venue | +| ↳ `capacity` | number | Seating capacity of the venue | +| ↳ `image_path` | string | Image path of the venue | +| ↳ `city_name` | string | Name of the city the venue is in | +| ↳ `surface` | string | Surface type of the venue | +| ↳ `national_team` | boolean | Whether the venue is used by the national team | + +### `sportmonks_football_get_venues` + +Retrieve all football venues available within your Sportmonks subscription + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;city\) | +| `filters` | string | No | Filters to apply \(e.g. venueCountries:98\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `venues` | array | Array of venue objects | +| ↳ `id` | number | Unique id of the venue | +| ↳ `country_id` | number | Country of the venue | +| ↳ `city_id` | number | City of the venue | +| ↳ `name` | string | Name of the venue | +| ↳ `address` | string | Address of the venue | +| ↳ `zipcode` | string | Zipcode of the venue | +| ↳ `latitude` | string | Latitude of the venue | +| ↳ `longitude` | string | Longitude of the venue | +| ↳ `capacity` | number | Seating capacity of the venue | +| ↳ `image_path` | string | Image path of the venue | +| ↳ `city_name` | string | Name of the city the venue is in | +| ↳ `surface` | string | Surface type of the venue | +| ↳ `national_team` | boolean | Whether the venue is used by the national team | + +### `sportmonks_football_get_venues_by_season` + +Retrieve all venues for a season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;city\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `venues` | array | Array of venue objects for the season | +| ↳ `id` | number | Unique id of the venue | +| ↳ `country_id` | number | Country of the venue | +| ↳ `city_id` | number | City of the venue | +| ↳ `name` | string | Name of the venue | +| ↳ `address` | string | Address of the venue | +| ↳ `zipcode` | string | Zipcode of the venue | +| ↳ `latitude` | string | Latitude of the venue | +| ↳ `longitude` | string | Longitude of the venue | +| ↳ `capacity` | number | Seating capacity of the venue | +| ↳ `image_path` | string | Image path of the venue | +| ↳ `city_name` | string | Name of the city the venue is in | +| ↳ `surface` | string | Surface type of the venue | +| ↳ `national_team` | boolean | Whether the venue is used by the national team | + +### `sportmonks_football_search_coaches` + +Search for football coaches by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The coach name to search for \(e.g. Gerrard\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;teams\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `coaches` | array | Array of coach objects matching the search query | +| ↳ `id` | number | Unique id of the coach | +| ↳ `player_id` | number | Player related to the coach | +| ↳ `sport_id` | number | Sport of the coach | +| ↳ `country_id` | number | Country of the coach | +| ↳ `nationality_id` | number | Nationality of the coach | +| ↳ `city_id` | number | Birth city of the coach | +| ↳ `common_name` | string | Common name of the coach | +| ↳ `firstname` | string | First name of the coach | +| ↳ `lastname` | string | Last name of the coach | +| ↳ `name` | string | Name of the coach | +| ↳ `display_name` | string | Display name of the coach | +| ↳ `image_path` | string | URL to the coach headshot | +| ↳ `height` | number | Height of the coach in cm | +| ↳ `weight` | number | Weight of the coach in kg | +| ↳ `date_of_birth` | string | Date of birth of the coach | +| ↳ `gender` | string | Gender of the coach | + +### `sportmonks_football_search_fixtures` + +Search for football fixtures by name (participants) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The fixture name to search for \(e.g. Celtic\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;scores\) | +| `filters` | string | No | Filters to apply \(e.g. fixtureLeagues:501\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order fixtures by starting_at \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of fixture objects matching the search query | +| ↳ `id` | number | Unique id of the fixture | +| ↳ `sport_id` | number | Sport the fixture is played at | +| ↳ `league_id` | number | League the fixture is played in | +| ↳ `season_id` | number | Season the fixture is played in | +| ↳ `stage_id` | number | Stage the fixture is played in | +| ↳ `group_id` | number | Group the fixture is played in | +| ↳ `aggregate_id` | number | Aggregate the fixture belongs to | +| ↳ `round_id` | number | Round the fixture is played in | +| ↳ `state_id` | number | State \(status\) of the fixture | +| ↳ `venue_id` | number | Venue the fixture is played at | +| ↳ `name` | string | Name of the fixture \(participants\) | +| ↳ `starting_at` | string | Datetime the fixture starts | +| ↳ `result_info` | string | Final result summary | +| ↳ `leg` | string | Leg of the fixture \(e.g. 1/1\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Length of the fixture in minutes | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Whether odds are available | +| ↳ `has_premium_odds` | boolean | Whether premium odds are available | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_football_search_leagues` + +Search for football leagues by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The league name to search for \(e.g. Premier\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;currentSeason\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order leagues \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of league objects matching the search query | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | number | Whether the league is active \(1\) or inactive \(0\) | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date the last fixture was played | +| ↳ `category` | number | Importance category of the league \(1-4\) | + +### `sportmonks_football_search_players` + +Search for football players by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The player name to search for \(e.g. Tavernier\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;position;teams.team\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order players by id \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `players` | array | Array of player objects matching the search query | +| ↳ `id` | number | Unique id of the player | +| ↳ `sport_id` | number | Sport of the player | +| ↳ `country_id` | number | Country of birth of the player | +| ↳ `nationality_id` | number | Nationality of the player | +| ↳ `city_id` | number | City of birth of the player | +| ↳ `position_id` | number | Position of the player | +| ↳ `detailed_position_id` | number | Detailed position of the player | +| ↳ `type_id` | number | Type of the player | +| ↳ `common_name` | string | Name the player is known for | +| ↳ `firstname` | string | First name of the player | +| ↳ `lastname` | string | Last name of the player | +| ↳ `name` | string | Name of the player | +| ↳ `display_name` | string | Display name of the player | +| ↳ `image_path` | string | URL to the player headshot | +| ↳ `height` | number | Height of the player in cm | +| ↳ `weight` | number | Weight of the player in kg | +| ↳ `date_of_birth` | string | Date of birth of the player | +| ↳ `gender` | string | Gender of the player | + +### `sportmonks_football_search_referees` + +Search for football referees by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The referee name to search for | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `referees` | array | Array of referee objects matching the search query | +| ↳ `id` | number | Unique id of the referee | +| ↳ `sport_id` | number | Sport of the referee | +| ↳ `country_id` | number | Country of the referee | +| ↳ `nationality_id` | number | Nationality of the referee | +| ↳ `city_id` | number | Birth city of the referee | +| ↳ `common_name` | string | Common name of the referee | +| ↳ `firstname` | string | First name of the referee | +| ↳ `lastname` | string | Last name of the referee | +| ↳ `name` | string | Name of the referee | +| ↳ `display_name` | string | Display name of the referee | +| ↳ `image_path` | string | URL to the referee headshot | +| ↳ `height` | number | Height of the referee in cm | +| ↳ `weight` | number | Weight of the referee in kg | +| ↳ `date_of_birth` | string | Date of birth of the referee | +| ↳ `gender` | string | Gender of the referee | + +### `sportmonks_football_search_rounds` + +Search for football rounds by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The round name to search for \(e.g. 5\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;season\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rounds` | array | Array of round objects matching the search query | +| ↳ `id` | number | Unique id of the round | +| ↳ `sport_id` | number | Sport of the round | +| ↳ `league_id` | number | League of the round | +| ↳ `season_id` | number | Season of the round | +| ↳ `stage_id` | number | Stage of the round | +| ↳ `name` | string | Name of the round | +| ↳ `finished` | boolean | Whether the round is finished | +| ↳ `is_current` | boolean | Whether the round is the current round | +| ↳ `starting_at` | string | Start date of the round | +| ↳ `ending_at` | string | End date of the round | +| ↳ `games_in_current_week` | boolean | Whether the round has fixtures this week | + +### `sportmonks_football_search_seasons` + +Search for football seasons by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The season name to search for \(e.g. 2023/2024\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `seasons` | array | Array of season objects matching the search query | +| ↳ `id` | number | Unique id of the season | +| ↳ `sport_id` | number | Sport of the season | +| ↳ `league_id` | number | League of the season | +| ↳ `tie_breaker_rule_id` | number | Tie-breaker rule of the season | +| ↳ `name` | string | Name of the season \(e.g. 2023/2024\) | +| ↳ `finished` | boolean | Whether the season is finished | +| ↳ `pending` | boolean | Whether the season is pending | +| ↳ `is_current` | boolean | Whether the season is the current season | +| ↳ `standing_method` | string | Standing calculation method | +| ↳ `starting_at` | string | Start date of the season | +| ↳ `ending_at` | string | End date of the season | +| ↳ `standings_recalculated_at` | string | Last standings recalculation time | +| ↳ `games_in_current_week` | boolean | Whether the season has fixtures this week | + +### `sportmonks_football_search_stages` + +Search for football stages by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The stage name to search for \(e.g. Group\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;season\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stages` | array | Array of stage objects matching the search query | +| ↳ `id` | number | Unique id of the stage | +| ↳ `sport_id` | number | Sport of the stage | +| ↳ `league_id` | number | League of the stage | +| ↳ `season_id` | number | Season of the stage | +| ↳ `type_id` | number | Type of the stage | +| ↳ `name` | string | Name of the stage | +| ↳ `sort_order` | number | Sort order of the stage | +| ↳ `finished` | boolean | Whether the stage is finished | +| ↳ `is_current` | boolean | Whether the stage is the current stage | +| ↳ `starting_at` | string | Start date of the stage | +| ↳ `ending_at` | string | End date of the stage | +| ↳ `games_in_current_week` | boolean | Whether the stage has fixtures this week | + +### `sportmonks_football_search_teams` + +Search for football teams by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The team name to search for \(e.g. Celtic\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;venue\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order teams by id \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teams` | array | Array of team objects matching the search query | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Home venue of the team | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the last played match | + +### `sportmonks_football_search_venues` + +Search for football venues by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The venue name to search for \(e.g. Celtic Park\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;city\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `venues` | array | Array of venue objects matching the search query | +| ↳ `id` | number | Unique id of the venue | +| ↳ `country_id` | number | Country of the venue | +| ↳ `city_id` | number | City of the venue | +| ↳ `name` | string | Name of the venue | +| ↳ `address` | string | Address of the venue | +| ↳ `zipcode` | string | Zipcode of the venue | +| ↳ `latitude` | string | Latitude of the venue | +| ↳ `longitude` | string | Longitude of the venue | +| ↳ `capacity` | number | Seating capacity of the venue | +| ↳ `image_path` | string | Image path of the venue | +| ↳ `city_name` | string | Name of the city the venue is in | +| ↳ `surface` | string | Surface type of the venue | +| ↳ `national_team` | boolean | Whether the venue is used by the national team | + +### `sportmonks_motorsport_get_all_fixtures` + +Retrieve all motorsport fixtures (sessions) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;results\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of motorsport fixture \(session\) objects | +| ↳ `id` | number | Unique id of the fixture \(session\) | +| ↳ `sport_id` | number | Sport of the fixture | +| ↳ `league_id` | number | League the fixture is held in | +| ↳ `season_id` | number | Season the fixture is held in | +| ↳ `stage_id` | number | Stage \(race weekend\) the fixture is held in | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `aggregate_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `state_id` | number | State the fixture is currently in | +| ↳ `venue_id` | number | Venue \(track\) the fixture is held at | +| ↳ `name` | string | Name of the fixture \(e.g. Practice 1, Race\) | +| ↳ `starting_at` | string | Start date and time | +| ↳ `result_info` | string | Final result info | +| ↳ `leg` | string | Stage of the fixture \(e.g. 2/3 for Practice 2\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Session length in minutes or total laps | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Not used in the Motorsport API | +| ↳ `has_premium_odds` | boolean | Not used in the Motorsport API | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_motorsport_get_current_leagues_by_team` + +Retrieve the current motorsport leagues for a team by team ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team \(constructor\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;seasons\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of current league objects for the team | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | boolean | Whether the league is active | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date of the last fixture held in the league | +| ↳ `category` | number | Category of the league | +| ↳ `has_jerseys` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_driver` + +Retrieve a single motorsport driver by their ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `driverId` | string | Yes | The unique id of the driver | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;teams\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `driver` | object | The requested driver object | +| ↳ `id` | number | Unique id of the driver \(player_id in responses\) | +| ↳ `sport_id` | number | Sport of the driver | +| ↳ `country_id` | number | Country of birth of the driver | +| ↳ `nationality_id` | number | Nationality of the driver | +| ↳ `city_id` | number | City of birth of the driver | +| ↳ `position_id` | number | Position of the driver within the team | +| ↳ `detailed_position_id` | number | Not used in the Motorsport API | +| ↳ `type_id` | number | Not used in the Motorsport API | +| ↳ `common_name` | string | Name the driver is known for | +| ↳ `firstname` | string | First name of the driver | +| ↳ `lastname` | string | Last name of the driver | +| ↳ `name` | string | Name of the driver | +| ↳ `display_name` | string | Display name of the driver | +| ↳ `image_path` | string | URL to the driver headshot | +| ↳ `height` | number | Height of the driver in cm | +| ↳ `weight` | number | Weight of the driver in kg | +| ↳ `date_of_birth` | string | Date of birth of the driver | +| ↳ `gender` | string | Gender of the driver | + +### `sportmonks_motorsport_get_driver_standings` + +Retrieve all driver championship standings from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;season\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `standings` | array | Array of driver standing entries | +| ↳ `id` | number | Unique id of the standing | +| ↳ `participant_id` | number | Driver or team related to the standing | +| ↳ `sport_id` | number | Sport related to the standing | +| ↳ `league_id` | number | League related to the standing | +| ↳ `season_id` | number | Season related to the standing | +| ↳ `stage_id` | number | Stage related to the standing | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `standing_rule_id` | number | Not used in the Motorsport API | +| ↳ `position` | number | Position of the participant in the standing | +| ↳ `result` | string | Not used in the Motorsport API | +| ↳ `points` | number | Points the participant has gathered | + +### `sportmonks_motorsport_get_driver_standings_by_season` + +Retrieve the drivers championship standings for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;season\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `standings` | array | Array of driver standing entries for the season | +| ↳ `id` | number | Unique id of the standing | +| ↳ `participant_id` | number | Driver or team related to the standing | +| ↳ `sport_id` | number | Sport related to the standing | +| ↳ `league_id` | number | League related to the standing | +| ↳ `season_id` | number | Season related to the standing | +| ↳ `stage_id` | number | Stage related to the standing | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `standing_rule_id` | number | Not used in the Motorsport API | +| ↳ `position` | number | Position of the participant in the standing | +| ↳ `result` | string | Not used in the Motorsport API | +| ↳ `points` | number | Points the participant has gathered | + +### `sportmonks_motorsport_get_drivers` + +Retrieve all motorsport drivers from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;teams\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `drivers` | array | Array of driver objects | +| ↳ `id` | number | Unique id of the driver \(player_id in responses\) | +| ↳ `sport_id` | number | Sport of the driver | +| ↳ `country_id` | number | Country of birth of the driver | +| ↳ `nationality_id` | number | Nationality of the driver | +| ↳ `city_id` | number | City of birth of the driver | +| ↳ `position_id` | number | Position of the driver within the team | +| ↳ `detailed_position_id` | number | Not used in the Motorsport API | +| ↳ `type_id` | number | Not used in the Motorsport API | +| ↳ `common_name` | string | Name the driver is known for | +| ↳ `firstname` | string | First name of the driver | +| ↳ `lastname` | string | Last name of the driver | +| ↳ `name` | string | Name of the driver | +| ↳ `display_name` | string | Display name of the driver | +| ↳ `image_path` | string | URL to the driver headshot | +| ↳ `height` | number | Height of the driver in cm | +| ↳ `weight` | number | Weight of the driver in kg | +| ↳ `date_of_birth` | string | Date of birth of the driver | +| ↳ `gender` | string | Gender of the driver | + +### `sportmonks_motorsport_get_drivers_by_country` + +Retrieve all motorsport drivers for a country by country ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `countryId` | string | Yes | The unique id of the country | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;teams\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `drivers` | array | Array of driver objects for the country | +| ↳ `id` | number | Unique id of the driver \(player_id in responses\) | +| ↳ `sport_id` | number | Sport of the driver | +| ↳ `country_id` | number | Country of birth of the driver | +| ↳ `nationality_id` | number | Nationality of the driver | +| ↳ `city_id` | number | City of birth of the driver | +| ↳ `position_id` | number | Position of the driver within the team | +| ↳ `detailed_position_id` | number | Not used in the Motorsport API | +| ↳ `type_id` | number | Not used in the Motorsport API | +| ↳ `common_name` | string | Name the driver is known for | +| ↳ `firstname` | string | First name of the driver | +| ↳ `lastname` | string | Last name of the driver | +| ↳ `name` | string | Name of the driver | +| ↳ `display_name` | string | Display name of the driver | +| ↳ `image_path` | string | URL to the driver headshot | +| ↳ `height` | number | Height of the driver in cm | +| ↳ `weight` | number | Weight of the driver in kg | +| ↳ `date_of_birth` | string | Date of birth of the driver | +| ↳ `gender` | string | Gender of the driver | + +### `sportmonks_motorsport_get_drivers_by_season` + +Retrieve all motorsport drivers for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;teams\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `drivers` | array | Array of driver objects for the season | +| ↳ `id` | number | Unique id of the driver \(player_id in responses\) | +| ↳ `sport_id` | number | Sport of the driver | +| ↳ `country_id` | number | Country of birth of the driver | +| ↳ `nationality_id` | number | Nationality of the driver | +| ↳ `city_id` | number | City of birth of the driver | +| ↳ `position_id` | number | Position of the driver within the team | +| ↳ `detailed_position_id` | number | Not used in the Motorsport API | +| ↳ `type_id` | number | Not used in the Motorsport API | +| ↳ `common_name` | string | Name the driver is known for | +| ↳ `firstname` | string | First name of the driver | +| ↳ `lastname` | string | Last name of the driver | +| ↳ `name` | string | Name of the driver | +| ↳ `display_name` | string | Display name of the driver | +| ↳ `image_path` | string | URL to the driver headshot | +| ↳ `height` | number | Height of the driver in cm | +| ↳ `weight` | number | Weight of the driver in kg | +| ↳ `date_of_birth` | string | Date of birth of the driver | +| ↳ `gender` | string | Gender of the driver | + +### `sportmonks_motorsport_get_fixture` + +Retrieve a single motorsport fixture (session) by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;results;latestLaps;pitstops\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixture` | object | The requested motorsport fixture \(session\) object | +| ↳ `id` | number | Unique id of the fixture \(session\) | +| ↳ `sport_id` | number | Sport of the fixture | +| ↳ `league_id` | number | League the fixture is held in | +| ↳ `season_id` | number | Season the fixture is held in | +| ↳ `stage_id` | number | Stage \(race weekend\) the fixture is held in | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `aggregate_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `state_id` | number | State the fixture is currently in | +| ↳ `venue_id` | number | Venue \(track\) the fixture is held at | +| ↳ `name` | string | Name of the fixture \(e.g. Practice 1, Race\) | +| ↳ `starting_at` | string | Start date and time | +| ↳ `result_info` | string | Final result info | +| ↳ `leg` | string | Stage of the fixture \(e.g. 2/3 for Practice 2\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Session length in minutes or total laps | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Not used in the Motorsport API | +| ↳ `has_premium_odds` | boolean | Not used in the Motorsport API | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_motorsport_get_fixtures_by_date` + +Retrieve motorsport fixtures (sessions) on a specific date (YYYY-MM-DD) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `date` | string | Yes | The date to fetch fixtures for, in YYYY-MM-DD format | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;venue\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order fixtures by starting_at \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of motorsport fixture \(session\) objects for the requested date | +| ↳ `id` | number | Unique id of the fixture \(session\) | +| ↳ `sport_id` | number | Sport of the fixture | +| ↳ `league_id` | number | League the fixture is held in | +| ↳ `season_id` | number | Season the fixture is held in | +| ↳ `stage_id` | number | Stage \(race weekend\) the fixture is held in | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `aggregate_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `state_id` | number | State the fixture is currently in | +| ↳ `venue_id` | number | Venue \(track\) the fixture is held at | +| ↳ `name` | string | Name of the fixture \(e.g. Practice 1, Race\) | +| ↳ `starting_at` | string | Start date and time | +| ↳ `result_info` | string | Final result info | +| ↳ `leg` | string | Stage of the fixture \(e.g. 2/3 for Practice 2\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Session length in minutes or total laps | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Not used in the Motorsport API | +| ↳ `has_premium_odds` | boolean | Not used in the Motorsport API | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_motorsport_get_fixtures_by_date_range` + +Retrieve motorsport fixtures (sessions) between two dates (YYYY-MM-DD, max 100 days) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `startDate` | string | Yes | The start date of the range, in YYYY-MM-DD format | +| `endDate` | string | Yes | The end date of the range, in YYYY-MM-DD format | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;venue\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order fixtures by starting_at \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of motorsport fixture \(session\) objects within the requested date range | +| ↳ `id` | number | Unique id of the fixture \(session\) | +| ↳ `sport_id` | number | Sport of the fixture | +| ↳ `league_id` | number | League the fixture is held in | +| ↳ `season_id` | number | Season the fixture is held in | +| ↳ `stage_id` | number | Stage \(race weekend\) the fixture is held in | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `aggregate_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `state_id` | number | State the fixture is currently in | +| ↳ `venue_id` | number | Venue \(track\) the fixture is held at | +| ↳ `name` | string | Name of the fixture \(e.g. Practice 1, Race\) | +| ↳ `starting_at` | string | Start date and time | +| ↳ `result_info` | string | Final result info | +| ↳ `leg` | string | Stage of the fixture \(e.g. 2/3 for Practice 2\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Session length in minutes or total laps | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Not used in the Motorsport API | +| ↳ `has_premium_odds` | boolean | Not used in the Motorsport API | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_motorsport_get_fixtures_by_ids` + +Retrieve multiple motorsport fixtures (sessions) by their IDs (max 50) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureIds` | string | Yes | Comma-separated list of fixture ids \(max 50, e.g. 19408487,19408480\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;results\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of motorsport fixture \(session\) objects for the requested ids | +| ↳ `id` | number | Unique id of the fixture \(session\) | +| ↳ `sport_id` | number | Sport of the fixture | +| ↳ `league_id` | number | League the fixture is held in | +| ↳ `season_id` | number | Season the fixture is held in | +| ↳ `stage_id` | number | Stage \(race weekend\) the fixture is held in | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `aggregate_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `state_id` | number | State the fixture is currently in | +| ↳ `venue_id` | number | Venue \(track\) the fixture is held at | +| ↳ `name` | string | Name of the fixture \(e.g. Practice 1, Race\) | +| ↳ `starting_at` | string | Start date and time | +| ↳ `result_info` | string | Final result info | +| ↳ `leg` | string | Stage of the fixture \(e.g. 2/3 for Practice 2\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Session length in minutes or total laps | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Not used in the Motorsport API | +| ↳ `has_premium_odds` | boolean | Not used in the Motorsport API | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_motorsport_get_laps_by_fixture` + +Retrieve all laps for a motorsport fixture (session) by fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `laps` | array | Array of lap objects for the fixture | +| ↳ `id` | number | Unique id of the lap/pitstop | +| ↳ `fixture_id` | number | Fixture related to the lap/pitstop | +| ↳ `lap_number` | number | Lap number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the lap/pitstop | +| ↳ `is_latest` | boolean | Whether it is the latest lap/pitstop | + +### `sportmonks_motorsport_get_laps_by_fixture_and_driver` + +Retrieve all laps for a motorsport fixture and driver from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `driverId` | string | Yes | The unique id of the driver | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `laps` | array | Array of lap objects for the fixture and driver | +| ↳ `id` | number | Unique id of the lap/pitstop | +| ↳ `fixture_id` | number | Fixture related to the lap/pitstop | +| ↳ `lap_number` | number | Lap number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the lap/pitstop | +| ↳ `is_latest` | boolean | Whether it is the latest lap/pitstop | + +### `sportmonks_motorsport_get_laps_by_fixture_and_lap` + +Retrieve all laps for a motorsport fixture and lap number from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `lapNumber` | string | Yes | The lap number to retrieve | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `laps` | array | Array of lap objects for the fixture and lap number | +| ↳ `id` | number | Unique id of the lap/pitstop | +| ↳ `fixture_id` | number | Fixture related to the lap/pitstop | +| ↳ `lap_number` | number | Lap number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the lap/pitstop | +| ↳ `is_latest` | boolean | Whether it is the latest lap/pitstop | + +### `sportmonks_motorsport_get_latest_laps_by_fixture` + +Retrieve the latest laps for a motorsport fixture (session) by fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `laps` | array | Array of the latest lap objects for the fixture | +| ↳ `id` | number | Unique id of the lap/pitstop | +| ↳ `fixture_id` | number | Fixture related to the lap/pitstop | +| ↳ `lap_number` | number | Lap number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the lap/pitstop | +| ↳ `is_latest` | boolean | Whether it is the latest lap/pitstop | + +### `sportmonks_motorsport_get_latest_pitstops_by_fixture` + +Retrieve the latest pitstops for a motorsport fixture (session) by fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pitstops` | array | Array of the latest pitstop objects for the fixture | +| ↳ `id` | number | Unique id of the lap/pitstop | +| ↳ `fixture_id` | number | Fixture related to the lap/pitstop | +| ↳ `lap_number` | number | Lap number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the lap/pitstop | +| ↳ `is_latest` | boolean | Whether it is the latest lap/pitstop | + +### `sportmonks_motorsport_get_latest_stints_by_fixture` + +Retrieve the latest tyre stints for a motorsport fixture (session) by fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stints` | array | Array of the latest stint objects for the fixture | +| ↳ `id` | number | Unique id of the stint | +| ↳ `fixture_id` | number | Fixture related to the stint | +| ↳ `stint_number` | number | Stint number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the stint | +| ↳ `is_latest` | boolean | Whether it is the latest stint | + +### `sportmonks_motorsport_get_latest_updated_drivers` + +Retrieve the most recently updated motorsport drivers from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;teams\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `drivers` | array | Array of recently updated driver objects | +| ↳ `id` | number | Unique id of the driver \(player_id in responses\) | +| ↳ `sport_id` | number | Sport of the driver | +| ↳ `country_id` | number | Country of birth of the driver | +| ↳ `nationality_id` | number | Nationality of the driver | +| ↳ `city_id` | number | City of birth of the driver | +| ↳ `position_id` | number | Position of the driver within the team | +| ↳ `detailed_position_id` | number | Not used in the Motorsport API | +| ↳ `type_id` | number | Not used in the Motorsport API | +| ↳ `common_name` | string | Name the driver is known for | +| ↳ `firstname` | string | First name of the driver | +| ↳ `lastname` | string | Last name of the driver | +| ↳ `name` | string | Name of the driver | +| ↳ `display_name` | string | Display name of the driver | +| ↳ `image_path` | string | URL to the driver headshot | +| ↳ `height` | number | Height of the driver in cm | +| ↳ `weight` | number | Weight of the driver in kg | +| ↳ `date_of_birth` | string | Date of birth of the driver | +| ↳ `gender` | string | Gender of the driver | + +### `sportmonks_motorsport_get_latest_updated_fixtures` + +Retrieve the most recently updated motorsport fixtures (sessions) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;results\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of recently updated motorsport fixture \(session\) objects | +| ↳ `id` | number | Unique id of the fixture \(session\) | +| ↳ `sport_id` | number | Sport of the fixture | +| ↳ `league_id` | number | League the fixture is held in | +| ↳ `season_id` | number | Season the fixture is held in | +| ↳ `stage_id` | number | Stage \(race weekend\) the fixture is held in | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `aggregate_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `state_id` | number | State the fixture is currently in | +| ↳ `venue_id` | number | Venue \(track\) the fixture is held at | +| ↳ `name` | string | Name of the fixture \(e.g. Practice 1, Race\) | +| ↳ `starting_at` | string | Start date and time | +| ↳ `result_info` | string | Final result info | +| ↳ `leg` | string | Stage of the fixture \(e.g. 2/3 for Practice 2\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Session length in minutes or total laps | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Not used in the Motorsport API | +| ↳ `has_premium_odds` | boolean | Not used in the Motorsport API | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_motorsport_get_league` + +Retrieve a single motorsport league by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `leagueId` | string | Yes | The unique id of the league | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;seasons\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `league` | object | The requested league object | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | boolean | Whether the league is active | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date of the last fixture held in the league | +| ↳ `category` | number | Category of the league | +| ↳ `has_jerseys` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_leagues` + +Retrieve all motorsport leagues from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;seasons\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of league objects | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | boolean | Whether the league is active | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date of the last fixture held in the league | +| ↳ `category` | number | Category of the league | +| ↳ `has_jerseys` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_leagues_by_country` + +Retrieve all motorsport leagues for a country by country ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `countryId` | string | Yes | The unique id of the country | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;seasons\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of league objects for the country | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | boolean | Whether the league is active | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date of the last fixture held in the league | +| ↳ `category` | number | Category of the league | +| ↳ `has_jerseys` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_leagues_by_date` + +Retrieve all motorsport leagues with fixtures on a specific date (YYYY-MM-DD) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `date` | string | Yes | The date to fetch leagues for, in YYYY-MM-DD format | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;seasons\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of league objects with fixtures on the requested date | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | boolean | Whether the league is active | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date of the last fixture held in the league | +| ↳ `category` | number | Category of the league | +| ↳ `has_jerseys` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_leagues_by_live` + +Retrieve all motorsport leagues that currently have live fixtures from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;seasons\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of league objects that currently have live fixtures | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | boolean | Whether the league is active | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date of the last fixture held in the league | +| ↳ `category` | number | Category of the league | +| ↳ `has_jerseys` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_leagues_by_team` + +Retrieve all current and historical motorsport leagues for a team by team ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team \(constructor\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;seasons\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of league objects for the team | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | boolean | Whether the league is active | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date of the last fixture held in the league | +| ↳ `category` | number | Category of the league | +| ↳ `has_jerseys` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_livescores` + +Retrieve all live motorsport fixtures (sessions) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participants;results\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `fixtures` | array | Array of live motorsport fixture \(session\) objects | +| ↳ `id` | number | Unique id of the fixture \(session\) | +| ↳ `sport_id` | number | Sport of the fixture | +| ↳ `league_id` | number | League the fixture is held in | +| ↳ `season_id` | number | Season the fixture is held in | +| ↳ `stage_id` | number | Stage \(race weekend\) the fixture is held in | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `aggregate_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `state_id` | number | State the fixture is currently in | +| ↳ `venue_id` | number | Venue \(track\) the fixture is held at | +| ↳ `name` | string | Name of the fixture \(e.g. Practice 1, Race\) | +| ↳ `starting_at` | string | Start date and time | +| ↳ `result_info` | string | Final result info | +| ↳ `leg` | string | Stage of the fixture \(e.g. 2/3 for Practice 2\) | +| ↳ `details` | string | Details about the fixture | +| ↳ `length` | number | Session length in minutes or total laps | +| ↳ `placeholder` | boolean | Whether the fixture is a placeholder | +| ↳ `has_odds` | boolean | Not used in the Motorsport API | +| ↳ `has_premium_odds` | boolean | Not used in the Motorsport API | +| ↳ `starting_at_timestamp` | number | UNIX timestamp of the start time | + +### `sportmonks_motorsport_get_pitstops_by_fixture` + +Retrieve all pitstops for a motorsport fixture (session) by fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pitstops` | array | Array of pitstop objects for the fixture | +| ↳ `id` | number | Unique id of the lap/pitstop | +| ↳ `fixture_id` | number | Fixture related to the lap/pitstop | +| ↳ `lap_number` | number | Lap number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the lap/pitstop | +| ↳ `is_latest` | boolean | Whether it is the latest lap/pitstop | + +### `sportmonks_motorsport_get_pitstops_by_fixture_and_driver` + +Retrieve all pitstops for a motorsport fixture and driver from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `driverId` | string | Yes | The unique id of the driver | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pitstops` | array | Array of pitstop objects for the fixture and driver | +| ↳ `id` | number | Unique id of the lap/pitstop | +| ↳ `fixture_id` | number | Fixture related to the lap/pitstop | +| ↳ `lap_number` | number | Lap number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the lap/pitstop | +| ↳ `is_latest` | boolean | Whether it is the latest lap/pitstop | + +### `sportmonks_motorsport_get_pitstops_by_fixture_and_lap` + +Retrieve all pitstops for a motorsport fixture and lap number from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `lapNumber` | string | Yes | The lap number to retrieve | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pitstops` | array | Array of pitstop objects for the fixture and lap number | +| ↳ `id` | number | Unique id of the lap/pitstop | +| ↳ `fixture_id` | number | Fixture related to the lap/pitstop | +| ↳ `lap_number` | number | Lap number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the lap/pitstop | +| ↳ `is_latest` | boolean | Whether it is the latest lap/pitstop | + +### `sportmonks_motorsport_get_race_results_by_season_and_driver` + +Retrieve race results (stages with fixtures, lineups and lineup details) for a season and driver from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `driverId` | string | Yes | The unique id of the driver | +| `include` | string | No | Semicolon-separated relations to enrich the response | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | array | Array of stage objects for the season and driver, each including nested fixtures, lineups and lineup details | +| ↳ `id` | number | Unique id of the stage \(race weekend\) | +| ↳ `sport_id` | number | Sport of the stage | +| ↳ `league_id` | number | League related to the stage | +| ↳ `season_id` | number | Season related to the stage | +| ↳ `type_id` | number | Type of the stage | +| ↳ `name` | string | Name of the stage | +| ↳ `sort_order` | number | Order of the stage | +| ↳ `finished` | boolean | Whether the stage is finished | +| ↳ `is_current` | boolean | Whether the stage is the current stage | +| ↳ `starting_at` | string | Starting date of the stage | +| ↳ `ending_at` | string | Ending date of the stage | +| ↳ `games_in_current_week` | boolean | Not used in the Motorsport API | +| ↳ `tie_breaker_rule_id` | number | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_race_results_by_season_and_team` + +Retrieve race results (stages with fixtures, lineups and lineup details) for a season and team from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `teamId` | string | Yes | The unique id of the team \(constructor\) | +| `include` | string | No | Semicolon-separated relations to enrich the response | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | array | Array of stage objects for the season and team, each including nested fixtures, lineups and lineup details | +| ↳ `id` | number | Unique id of the stage \(race weekend\) | +| ↳ `sport_id` | number | Sport of the stage | +| ↳ `league_id` | number | League related to the stage | +| ↳ `season_id` | number | Season related to the stage | +| ↳ `type_id` | number | Type of the stage | +| ↳ `name` | string | Name of the stage | +| ↳ `sort_order` | number | Order of the stage | +| ↳ `finished` | boolean | Whether the stage is finished | +| ↳ `is_current` | boolean | Whether the stage is the current stage | +| ↳ `starting_at` | string | Starting date of the stage | +| ↳ `ending_at` | string | Ending date of the stage | +| ↳ `games_in_current_week` | boolean | Not used in the Motorsport API | +| ↳ `tie_breaker_rule_id` | number | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_schedules_by_season` + +Retrieve the full schedule (stages with nested fixtures and venues) for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `schedules` | array | Array of stage objects for the season schedule, each including nested fixtures and venues | +| ↳ `id` | number | Unique id of the stage \(race weekend\) | +| ↳ `sport_id` | number | Sport of the stage | +| ↳ `league_id` | number | League related to the stage | +| ↳ `season_id` | number | Season related to the stage | +| ↳ `type_id` | number | Type of the stage | +| ↳ `name` | string | Name of the stage | +| ↳ `sort_order` | number | Order of the stage | +| ↳ `finished` | boolean | Whether the stage is finished | +| ↳ `is_current` | boolean | Whether the stage is the current stage | +| ↳ `starting_at` | string | Starting date of the stage | +| ↳ `ending_at` | string | Ending date of the stage | +| ↳ `games_in_current_week` | boolean | Not used in the Motorsport API | +| ↳ `tie_breaker_rule_id` | number | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_season` + +Retrieve a single motorsport season by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;stages\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `season` | object | The requested season object | +| ↳ `id` | number | Unique id of the season | +| ↳ `sport_id` | number | Sport of the season | +| ↳ `league_id` | number | League of the season | +| ↳ `tie_breaker_rule_id` | number | Not used in the Motorsport API | +| ↳ `name` | string | Name of the season | +| ↳ `finished` | boolean | Whether the season is finished | +| ↳ `pending` | boolean | Whether the season is pending | +| ↳ `is_current` | boolean | Whether the season is the current season | +| ↳ `starting_at` | string | Starting date of the season | +| ↳ `ending_at` | string | Ending date of the season | +| ↳ `standings_recalculated_at` | string | Timestamp when standings were last updated | +| ↳ `games_in_current_week` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_seasons` + +Retrieve all motorsport seasons from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;stages\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `seasons` | array | Array of season objects | +| ↳ `id` | number | Unique id of the season | +| ↳ `sport_id` | number | Sport of the season | +| ↳ `league_id` | number | League of the season | +| ↳ `tie_breaker_rule_id` | number | Not used in the Motorsport API | +| ↳ `name` | string | Name of the season | +| ↳ `finished` | boolean | Whether the season is finished | +| ↳ `pending` | boolean | Whether the season is pending | +| ↳ `is_current` | boolean | Whether the season is the current season | +| ↳ `starting_at` | string | Starting date of the season | +| ↳ `ending_at` | string | Ending date of the season | +| ↳ `standings_recalculated_at` | string | Timestamp when standings were last updated | +| ↳ `games_in_current_week` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_stage` + +Retrieve a single motorsport stage (race weekend) by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `stageId` | string | Yes | The unique id of the stage \(race weekend\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;season;fixtures\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stage` | object | The requested stage \(race weekend\) object | +| ↳ `id` | number | Unique id of the stage \(race weekend\) | +| ↳ `sport_id` | number | Sport of the stage | +| ↳ `league_id` | number | League related to the stage | +| ↳ `season_id` | number | Season related to the stage | +| ↳ `type_id` | number | Type of the stage | +| ↳ `name` | string | Name of the stage | +| ↳ `sort_order` | number | Order of the stage | +| ↳ `finished` | boolean | Whether the stage is finished | +| ↳ `is_current` | boolean | Whether the stage is the current stage | +| ↳ `starting_at` | string | Starting date of the stage | +| ↳ `ending_at` | string | Ending date of the stage | +| ↳ `games_in_current_week` | boolean | Not used in the Motorsport API | +| ↳ `tie_breaker_rule_id` | number | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_stages` + +Retrieve all motorsport stages (race weekends) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;season;fixtures\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stages` | array | Array of stage \(race weekend\) objects | +| ↳ `id` | number | Unique id of the stage \(race weekend\) | +| ↳ `sport_id` | number | Sport of the stage | +| ↳ `league_id` | number | League related to the stage | +| ↳ `season_id` | number | Season related to the stage | +| ↳ `type_id` | number | Type of the stage | +| ↳ `name` | string | Name of the stage | +| ↳ `sort_order` | number | Order of the stage | +| ↳ `finished` | boolean | Whether the stage is finished | +| ↳ `is_current` | boolean | Whether the stage is the current stage | +| ↳ `starting_at` | string | Starting date of the stage | +| ↳ `ending_at` | string | Ending date of the stage | +| ↳ `games_in_current_week` | boolean | Not used in the Motorsport API | +| ↳ `tie_breaker_rule_id` | number | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_stages_by_season` + +Retrieve all motorsport stages (race weekends) for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;season;fixtures\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stages` | array | Array of stage \(race weekend\) objects for the season | +| ↳ `id` | number | Unique id of the stage \(race weekend\) | +| ↳ `sport_id` | number | Sport of the stage | +| ↳ `league_id` | number | League related to the stage | +| ↳ `season_id` | number | Season related to the stage | +| ↳ `type_id` | number | Type of the stage | +| ↳ `name` | string | Name of the stage | +| ↳ `sort_order` | number | Order of the stage | +| ↳ `finished` | boolean | Whether the stage is finished | +| ↳ `is_current` | boolean | Whether the stage is the current stage | +| ↳ `starting_at` | string | Starting date of the stage | +| ↳ `ending_at` | string | Ending date of the stage | +| ↳ `games_in_current_week` | boolean | Not used in the Motorsport API | +| ↳ `tie_breaker_rule_id` | number | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_state` + +Retrieve a single motorsport fixture state by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `stateId` | string | Yes | The unique id of the state | +| `include` | string | No | Semicolon-separated relations to enrich the response | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `state` | object | The requested fixture state object | +| ↳ `id` | number | Unique id of the state | +| ↳ `state` | string | Abbreviation of the state | +| ↳ `name` | string | Full name of the state | +| ↳ `short_name` | string | Short name of the state | +| ↳ `developer_name` | string | Name recommended for developers to use | + +### `sportmonks_motorsport_get_states` + +Retrieve all possible motorsport fixture states from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `states` | array | Array of fixture state objects | +| ↳ `id` | number | Unique id of the state | +| ↳ `state` | string | Abbreviation of the state | +| ↳ `name` | string | Full name of the state | +| ↳ `short_name` | string | Short name of the state | +| ↳ `developer_name` | string | Name recommended for developers to use | + +### `sportmonks_motorsport_get_stints_by_fixture` + +Retrieve all tyre stints for a motorsport fixture (session) by fixture ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stints` | array | Array of stint objects for the fixture | +| ↳ `id` | number | Unique id of the stint | +| ↳ `fixture_id` | number | Fixture related to the stint | +| ↳ `stint_number` | number | Stint number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the stint | +| ↳ `is_latest` | boolean | Whether it is the latest stint | + +### `sportmonks_motorsport_get_stints_by_fixture_and_driver` + +Retrieve all tyre stints for a motorsport fixture and driver from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `driverId` | string | Yes | The unique id of the driver | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stints` | array | Array of stint objects for the fixture and driver | +| ↳ `id` | number | Unique id of the stint | +| ↳ `fixture_id` | number | Fixture related to the stint | +| ↳ `stint_number` | number | Stint number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the stint | +| ↳ `is_latest` | boolean | Whether it is the latest stint | + +### `sportmonks_motorsport_get_stints_by_fixture_and_stint` + +Retrieve all tyre stints for a motorsport fixture and stint number from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture \(session\) | +| `stintNumber` | string | Yes | The stint number to retrieve | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;details\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stints` | array | Array of stint objects for the fixture and stint number | +| ↳ `id` | number | Unique id of the stint | +| ↳ `fixture_id` | number | Fixture related to the stint | +| ↳ `stint_number` | number | Stint number in the fixture | +| ↳ `driver_number` | number | Number of the driver | +| ↳ `participant_id` | number | Driver related to the stint | +| ↳ `is_latest` | boolean | Whether it is the latest stint | + +### `sportmonks_motorsport_get_team` + +Retrieve a single motorsport team (constructor) by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `teamId` | string | Yes | The unique id of the team \(constructor\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;drivers\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `team` | object | The requested team \(constructor\) object | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Not used in the Motorsport API | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team \(constructor\) | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the team's last session | + +### `sportmonks_motorsport_get_team_standings` + +Retrieve all team (constructor) championship standings from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;season\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `standings` | array | Array of team \(constructor\) standing entries | +| ↳ `id` | number | Unique id of the standing | +| ↳ `participant_id` | number | Driver or team related to the standing | +| ↳ `sport_id` | number | Sport related to the standing | +| ↳ `league_id` | number | League related to the standing | +| ↳ `season_id` | number | Season related to the standing | +| ↳ `stage_id` | number | Stage related to the standing | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `standing_rule_id` | number | Not used in the Motorsport API | +| ↳ `position` | number | Position of the participant in the standing | +| ↳ `result` | string | Not used in the Motorsport API | +| ↳ `points` | number | Points the participant has gathered | + +### `sportmonks_motorsport_get_team_standings_by_season` + +Retrieve the constructors championship standings for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. participant;season\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `standings` | array | Array of team \(constructor\) standing entries for the season | +| ↳ `id` | number | Unique id of the standing | +| ↳ `participant_id` | number | Driver or team related to the standing | +| ↳ `sport_id` | number | Sport related to the standing | +| ↳ `league_id` | number | League related to the standing | +| ↳ `season_id` | number | Season related to the standing | +| ↳ `stage_id` | number | Stage related to the standing | +| ↳ `group_id` | number | Not used in the Motorsport API | +| ↳ `round_id` | number | Not used in the Motorsport API | +| ↳ `standing_rule_id` | number | Not used in the Motorsport API | +| ↳ `position` | number | Position of the participant in the standing | +| ↳ `result` | string | Not used in the Motorsport API | +| ↳ `points` | number | Points the participant has gathered | + +### `sportmonks_motorsport_get_teams` + +Retrieve all motorsport teams (constructors) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;drivers\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teams` | array | Array of team \(constructor\) objects | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Not used in the Motorsport API | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team \(constructor\) | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the team's last session | + +### `sportmonks_motorsport_get_teams_by_country` + +Retrieve all motorsport teams (constructors) for a country by country ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `countryId` | string | Yes | The unique id of the country | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;drivers\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teams` | array | Array of team \(constructor\) objects for the country | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Not used in the Motorsport API | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team \(constructor\) | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the team's last session | + +### `sportmonks_motorsport_get_teams_by_season` + +Retrieve all motorsport teams (constructors) for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;drivers\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teams` | array | Array of team \(constructor\) objects for the season | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Not used in the Motorsport API | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team \(constructor\) | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the team's last session | + +### `sportmonks_motorsport_get_venue` + +Retrieve a single motorsport venue (racing track) by its ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `venueId` | string | Yes | The unique id of the venue \(track\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;city\) | +| `filters` | string | No | Filters to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `venue` | object | The requested venue \(racing track\) object | +| ↳ `id` | number | Unique id of the venue \(track\) | +| ↳ `country_id` | number | Country the venue is in | +| ↳ `city_id` | number | City the venue is in | +| ↳ `name` | string | Name of the venue/track | +| ↳ `address` | string | Address of the venue | +| ↳ `zipcode` | string | Zipcode of the venue | +| ↳ `latitude` | string | Latitude of the venue | +| ↳ `longitude` | string | Longitude of the venue | +| ↳ `capacity` | number | Capacity of the venue | +| ↳ `image_path` | string | URL to the track layout image | +| ↳ `city_name` | string | Name of the city the venue is in | +| ↳ `surface` | string | Surface of the venue | +| ↳ `national_team` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_venues` + +Retrieve all motorsport venues (racing tracks) from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;city\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `venues` | array | Array of venue \(racing track\) objects | +| ↳ `id` | number | Unique id of the venue \(track\) | +| ↳ `country_id` | number | Country the venue is in | +| ↳ `city_id` | number | City the venue is in | +| ↳ `name` | string | Name of the venue/track | +| ↳ `address` | string | Address of the venue | +| ↳ `zipcode` | string | Zipcode of the venue | +| ↳ `latitude` | string | Latitude of the venue | +| ↳ `longitude` | string | Longitude of the venue | +| ↳ `capacity` | number | Capacity of the venue | +| ↳ `image_path` | string | URL to the track layout image | +| ↳ `city_name` | string | Name of the city the venue is in | +| ↳ `surface` | string | Surface of the venue | +| ↳ `national_team` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_get_venues_by_season` + +Retrieve all motorsport venues (racing tracks) for a season by season ID from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `seasonId` | string | Yes | The unique id of the season | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;city\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `venues` | array | Array of venue \(racing track\) objects for the season | +| ↳ `id` | number | Unique id of the venue \(track\) | +| ↳ `country_id` | number | Country the venue is in | +| ↳ `city_id` | number | City the venue is in | +| ↳ `name` | string | Name of the venue/track | +| ↳ `address` | string | Address of the venue | +| ↳ `zipcode` | string | Zipcode of the venue | +| ↳ `latitude` | string | Latitude of the venue | +| ↳ `longitude` | string | Longitude of the venue | +| ↳ `capacity` | number | Capacity of the venue | +| ↳ `image_path` | string | URL to the track layout image | +| ↳ `city_name` | string | Name of the city the venue is in | +| ↳ `surface` | string | Surface of the venue | +| ↳ `national_team` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_search_drivers` + +Search for motorsport drivers by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The driver name to search for \(e.g. Verstappen\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;teams\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `drivers` | array | Array of driver objects matching the search query | +| ↳ `id` | number | Unique id of the driver \(player_id in responses\) | +| ↳ `sport_id` | number | Sport of the driver | +| ↳ `country_id` | number | Country of birth of the driver | +| ↳ `nationality_id` | number | Nationality of the driver | +| ↳ `city_id` | number | City of birth of the driver | +| ↳ `position_id` | number | Position of the driver within the team | +| ↳ `detailed_position_id` | number | Not used in the Motorsport API | +| ↳ `type_id` | number | Not used in the Motorsport API | +| ↳ `common_name` | string | Name the driver is known for | +| ↳ `firstname` | string | First name of the driver | +| ↳ `lastname` | string | Last name of the driver | +| ↳ `name` | string | Name of the driver | +| ↳ `display_name` | string | Display name of the driver | +| ↳ `image_path` | string | URL to the driver headshot | +| ↳ `height` | number | Height of the driver in cm | +| ↳ `weight` | number | Weight of the driver in kg | +| ↳ `date_of_birth` | string | Date of birth of the driver | +| ↳ `gender` | string | Gender of the driver | + +### `sportmonks_motorsport_search_leagues` + +Search for motorsport leagues by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The league name to search for \(e.g. Formula\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;seasons\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leagues` | array | Array of league objects matching the search query | +| ↳ `id` | number | Unique id of the league | +| ↳ `sport_id` | number | Sport of the league | +| ↳ `country_id` | number | Country of the league | +| ↳ `name` | string | Name of the league | +| ↳ `active` | boolean | Whether the league is active | +| ↳ `short_code` | string | Short code of the league | +| ↳ `image_path` | string | URL to the league logo | +| ↳ `type` | string | Type of the league | +| ↳ `sub_type` | string | Subtype of the league | +| ↳ `last_played_at` | string | Date of the last fixture held in the league | +| ↳ `category` | number | Category of the league | +| ↳ `has_jerseys` | boolean | Not used in the Motorsport API | + +### `sportmonks_motorsport_search_stages` + +Search for motorsport stages (race weekends) by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The stage name to search for \(e.g. Monaco\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. league;season;fixtures\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stages` | array | Array of stage \(race weekend\) objects matching the search query | +| ↳ `id` | number | Unique id of the stage \(race weekend\) | +| ↳ `sport_id` | number | Sport of the stage | +| ↳ `league_id` | number | League related to the stage | +| ↳ `season_id` | number | Season related to the stage | +| ↳ `type_id` | number | Type of the stage | +| ↳ `name` | string | Name of the stage | +| ↳ `sort_order` | number | Order of the stage | +| ↳ `finished` | boolean | Whether the stage is finished | +| ↳ `is_current` | boolean | Whether the stage is the current stage | +| ↳ `starting_at` | string | Starting date of the stage | +| ↳ `ending_at` | string | Ending date of the stage | +| ↳ `games_in_current_week` | boolean | Not used in the Motorsport API | +| ↳ `tie_breaker_rule_id` | number | Not used in the Motorsport API | + +### `sportmonks_motorsport_search_teams` + +Search for motorsport teams (constructors) by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The team name to search for \(e.g. Bull\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;drivers\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `teams` | array | Array of team \(constructor\) objects matching the search query | +| ↳ `id` | number | Unique id of the team | +| ↳ `sport_id` | number | Sport of the team | +| ↳ `country_id` | number | Country of the team | +| ↳ `venue_id` | number | Not used in the Motorsport API | +| ↳ `gender` | string | Gender of the team | +| ↳ `name` | string | Name of the team \(constructor\) | +| ↳ `short_code` | string | Short code of the team | +| ↳ `image_path` | string | URL to the team logo | +| ↳ `founded` | number | Founding year of the team | +| ↳ `type` | string | Type of the team | +| ↳ `placeholder` | boolean | Whether the team is a placeholder | +| ↳ `last_played_at` | string | Date and time of the team's last session | + +### `sportmonks_motorsport_search_venues` + +Search for motorsport venues (racing tracks) by name from Sportmonks + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The venue name to search for \(e.g. Hungaroring\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;city\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `venues` | array | Array of venue \(racing track\) objects matching the search query | +| ↳ `id` | number | Unique id of the venue \(track\) | +| ↳ `country_id` | number | Country the venue is in | +| ↳ `city_id` | number | City the venue is in | +| ↳ `name` | string | Name of the venue/track | +| ↳ `address` | string | Address of the venue | +| ↳ `zipcode` | string | Zipcode of the venue | +| ↳ `latitude` | string | Latitude of the venue | +| ↳ `longitude` | string | Longitude of the venue | +| ↳ `capacity` | number | Capacity of the venue | +| ↳ `image_path` | string | URL to the track layout image | +| ↳ `city_name` | string | Name of the city the venue is in | +| ↳ `surface` | string | Surface of the venue | +| ↳ `national_team` | boolean | Not used in the Motorsport API | + +### `sportmonks_odds_get_all_historical_odds` + +Retrieve all available historical (premium) pre-match odd values from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. odd\) | +| `filters` | string | No | Filters to apply \(e.g. winningOdds\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `historicalOdds` | array | Array of historical premium odd value records | +| ↳ `id` | number | Unique id of the history record | +| ↳ `odd_id` | number | Premium odd this history record belongs to | +| ↳ `value` | string | Historical decimal odds value | +| ↳ `probability` | string | Implied probability at this point in time | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `bookmaker_update` | string | Bookmaker's update timestamp for this record \(UTC\) | + +### `sportmonks_odds_get_all_inplay_odds` + +Retrieve all available live (in-play) odds from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12, bookmakers:2,14, IdAfter:oddID\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `odds` | array | Array of in-play odd objects | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `external_id` | number | External id of the odd | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label \(e.g. 1, X, 2\) | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `winning` | boolean | Whether this is the winning outcome | +| ↳ `suspended` | boolean | Whether the odd is suspended | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `participants` | string | Participant ids related to the outcome | + +### `sportmonks_odds_get_all_pre_match_odds` + +Retrieve all available pre-match odds from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12, bookmakers:2,14, winningOdds, IdAfter:oddID\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `odds` | array | Array of pre-match odd objects | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label \(e.g. 1, X, 2\) | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name \(e.g. Home, Draw, Away\) | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability \(e.g. 48.78%\) | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds \(e.g. 31/15\) | +| ↳ `american` | string | American/moneyline odds \(e.g. +104\) | +| ↳ `winning` | boolean | Whether this is the winning outcome | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `participants` | string | Participant ids related to the outcome | +| ↳ `original_label` | string | Original handicap value of the odd \(handicap markets\) | + +### `sportmonks_odds_get_all_premium_odds` + +Retrieve all available premium (historical) pre-match odds from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12, bookmakers:2,14, IdAfter:oddID\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `premiumOdds` | array | Array of premium odd objects | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability \(e.g. 29.85%\) | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `created_at` | string | Timestamp the odd was created \(UTC\) | +| ↳ `updated_at` | string | Timestamp the odd was last updated \(UTC\) | +| ↳ `latest_bookmaker_update` | string | Bookmaker's own last-update timestamp \(UTC\) | + +### `sportmonks_odds_get_bookmaker` + +Retrieve a single bookmaker by its ID from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `bookmakerId` | string | Yes | The unique id of the bookmaker | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `bookmaker` | object | The requested bookmaker object | +| ↳ `id` | number | Unique id of the bookmaker | +| ↳ `name` | string | Name of the bookmaker | +| ↳ `logo` | string | Logo of the bookmaker | + +### `sportmonks_odds_get_bookmaker_event_ids_by_fixture` + +Retrieve bookmakers' own event ids mapped to a Sportmonks fixture via the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `bookmakerEvents` | array | Array of bookmaker event mapping records for the fixture | +| ↳ `fixture_id` | number | Sportmonks fixture id | +| ↳ `bookmaker_id` | number | Id of the bookmaker | +| ↳ `bookmaker_name` | string | Name of the bookmaker | +| ↳ `bookmaker_event_id` | string | The fixture's event id at the bookmaker | + +### `sportmonks_odds_get_bookmakers` + +Retrieve all bookmakers from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `filters` | string | No | Filters to apply \(e.g. IdAfter:bookmakerID\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `bookmakers` | array | Array of bookmaker objects | +| ↳ `id` | number | Unique id of the bookmaker | +| ↳ `name` | string | Name of the bookmaker | +| ↳ `logo` | string | Logo of the bookmaker | + +### `sportmonks_odds_get_bookmakers_by_fixture` + +Retrieve all bookmakers available for a fixture from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `bookmakers` | array | Array of bookmaker objects available for the fixture | +| ↳ `id` | number | Unique id of the bookmaker | +| ↳ `name` | string | Name of the bookmaker | +| ↳ `logo` | string | Logo of the bookmaker | + +### `sportmonks_odds_get_inplay_odds_by_fixture` + +Retrieve live (in-play) odds for a fixture by fixture ID from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12 or bookmakers:2,14 or winningOdds\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `odds` | array | Array of in-play odd objects for the fixture | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `external_id` | number | External id of the odd | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label \(e.g. 1, X, 2\) | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `winning` | boolean | Whether this is the winning outcome | +| ↳ `suspended` | boolean | Whether the odd is suspended | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `participants` | string | Participant ids related to the outcome | + +### `sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker` + +Retrieve live (in-play) odds for a fixture from a specific bookmaker via the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `bookmakerId` | string | Yes | The unique id of the bookmaker | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `odds` | array | Array of in-play odd objects for the fixture and bookmaker | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `external_id` | number | External id of the odd | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label \(e.g. 1, X, 2\) | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `winning` | boolean | Whether this is the winning outcome | +| ↳ `suspended` | boolean | Whether the odd is suspended | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `participants` | string | Participant ids related to the outcome | + +### `sportmonks_odds_get_inplay_odds_by_fixture_and_market` + +Retrieve live (in-play) odds for a fixture on a specific market via the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `marketId` | string | Yes | The unique id of the market | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. bookmakers:2,14\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `odds` | array | Array of in-play odd objects for the fixture and market | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `external_id` | number | External id of the odd | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label \(e.g. 1, X, 2\) | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `winning` | boolean | Whether this is the winning outcome | +| ↳ `suspended` | boolean | Whether the odd is suspended | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `participants` | string | Participant ids related to the outcome | + +### `sportmonks_odds_get_last_updated_inplay_odds` + +Retrieve in-play odds updated in the last 10 seconds from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12 or bookmakers:2,14\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `odds` | array | Array of in-play odd objects updated in the last 10 seconds | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `external_id` | number | External id of the odd | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label \(e.g. 1, X, 2\) | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `winning` | boolean | Whether this is the winning outcome | +| ↳ `suspended` | boolean | Whether the odd is suspended | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `participants` | string | Participant ids related to the outcome | + +### `sportmonks_odds_get_last_updated_pre_match_odds` + +Retrieve pre-match odds updated in the last 10 seconds from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12 or bookmakers:2,14 or winningOdds\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `odds` | array | Array of pre-match odd objects updated in the last 10 seconds | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label \(e.g. 1, X, 2\) | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name \(e.g. Home, Draw, Away\) | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability \(e.g. 48.78%\) | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds \(e.g. 31/15\) | +| ↳ `american` | string | American/moneyline odds \(e.g. +104\) | +| ↳ `winning` | boolean | Whether this is the winning outcome | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `participants` | string | Participant ids related to the outcome | +| ↳ `original_label` | string | Original handicap value of the odd \(handicap markets\) | + +### `sportmonks_odds_get_market` + +Retrieve a single betting market by its ID from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `marketId` | string | Yes | The unique id of the market | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `market` | object | The requested market object | +| ↳ `id` | number | Unique id of the market | +| ↳ `name` | string | Name of the market | +| ↳ `developer_name` | string | Developer \(machine-readable\) name of the market | + +### `sportmonks_odds_get_markets` + +Retrieve all betting markets from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `filters` | string | No | Filters to apply \(e.g. IdAfter:marketID\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `markets` | array | Array of market objects | +| ↳ `id` | number | Unique id of the market | +| ↳ `name` | string | Name of the market | +| ↳ `developer_name` | string | Developer \(machine-readable\) name of the market | + +### `sportmonks_odds_get_pre_match_odds_by_fixture` + +Retrieve pre-match odds for a fixture by fixture ID from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12 or bookmakers:2,14 or winningOdds\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `odds` | array | Array of pre-match odd objects for the fixture | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label \(e.g. 1, X, 2\) | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name \(e.g. Home, Draw, Away\) | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability \(e.g. 48.78%\) | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds \(e.g. 31/15\) | +| ↳ `american` | string | American/moneyline odds \(e.g. +104\) | +| ↳ `winning` | boolean | Whether this is the winning outcome | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `participants` | string | Participant ids related to the outcome | +| ↳ `original_label` | string | Original handicap value of the odd \(handicap markets\) | + +### `sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker` + +Retrieve pre-match odds for a fixture from a specific bookmaker via the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `bookmakerId` | string | Yes | The unique id of the bookmaker | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12 or winningOdds\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `odds` | array | Array of pre-match odd objects for the fixture and bookmaker | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label \(e.g. 1, X, 2\) | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name \(e.g. Home, Draw, Away\) | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability \(e.g. 48.78%\) | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds \(e.g. 31/15\) | +| ↳ `american` | string | American/moneyline odds \(e.g. +104\) | +| ↳ `winning` | boolean | Whether this is the winning outcome | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `participants` | string | Participant ids related to the outcome | +| ↳ `original_label` | string | Original handicap value of the odd \(handicap markets\) | + +### `sportmonks_odds_get_pre_match_odds_by_fixture_and_market` + +Retrieve pre-match odds for a fixture on a specific market via the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `marketId` | string | Yes | The unique id of the market | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. bookmakers:2,14 or winningOdds\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `odds` | array | Array of pre-match odd objects for the fixture and market | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label \(e.g. 1, X, 2\) | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name \(e.g. Home, Draw, Away\) | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability \(e.g. 48.78%\) | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds \(e.g. 31/15\) | +| ↳ `american` | string | American/moneyline odds \(e.g. +104\) | +| ↳ `winning` | boolean | Whether this is the winning outcome | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `participants` | string | Participant ids related to the outcome | +| ↳ `original_label` | string | Original handicap value of the odd \(handicap markets\) | + +### `sportmonks_odds_get_premium_odds_by_fixture` + +Retrieve premium (historical) pre-match odds for a fixture from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12 or bookmakers:2,14\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `premiumOdds` | array | Array of premium odd objects for the fixture | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability \(e.g. 29.85%\) | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `created_at` | string | Timestamp the odd was created \(UTC\) | +| ↳ `updated_at` | string | Timestamp the odd was last updated \(UTC\) | +| ↳ `latest_bookmaker_update` | string | Bookmaker's own last-update timestamp \(UTC\) | + +### `sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker` + +Retrieve premium pre-match odds for a fixture from a specific bookmaker via the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `bookmakerId` | string | Yes | The unique id of the bookmaker | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `premiumOdds` | array | Array of premium odd objects for the fixture and bookmaker | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability \(e.g. 29.85%\) | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `created_at` | string | Timestamp the odd was created \(UTC\) | +| ↳ `updated_at` | string | Timestamp the odd was last updated \(UTC\) | +| ↳ `latest_bookmaker_update` | string | Bookmaker's own last-update timestamp \(UTC\) | + +### `sportmonks_odds_get_premium_odds_by_fixture_and_market` + +Retrieve premium pre-match odds for a fixture on a specific market via the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fixtureId` | string | Yes | The unique id of the fixture | +| `marketId` | string | Yes | The unique id of the market | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. bookmakers:2,14\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `premiumOdds` | array | Array of premium odd objects for the fixture and market | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability \(e.g. 29.85%\) | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `created_at` | string | Timestamp the odd was created \(UTC\) | +| ↳ `updated_at` | string | Timestamp the odd was last updated \(UTC\) | +| ↳ `latest_bookmaker_update` | string | Bookmaker's own last-update timestamp \(UTC\) | + +### `sportmonks_odds_get_updated_historical_odds_between` + +Retrieve historical (premium) odds updated between two UNIX timestamps (max 5 minutes) from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fromTimestamp` | string | Yes | Start of the range as a UNIX timestamp \(e.g. 1767225600\) | +| `toTimestamp` | string | Yes | End of the range as a UNIX timestamp \(max 5 minutes after the start\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. odd\) | +| `filters` | string | No | Filters to apply \(e.g. winningOdds\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `historicalOdds` | array | Array of historical premium odd value records updated within the time range | +| ↳ `id` | number | Unique id of the history record | +| ↳ `odd_id` | number | Premium odd this history record belongs to | +| ↳ `value` | string | Historical decimal odds value | +| ↳ `probability` | string | Implied probability at this point in time | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `bookmaker_update` | string | Bookmaker's update timestamp for this record \(UTC\) | + +### `sportmonks_odds_get_updated_premium_odds_between` + +Retrieve premium odds updated between two UNIX timestamps (max 5 minutes) from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `fromTimestamp` | string | Yes | Start of the range as a UNIX timestamp \(e.g. 1767225600\) | +| `toTimestamp` | string | Yes | End of the range as a UNIX timestamp \(max 5 minutes after the start\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. market;bookmaker\) | +| `filters` | string | No | Filters to apply \(e.g. markets:1,12 or bookmakers:2,14\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `premiumOdds` | array | Array of premium odd objects updated within the time range | +| ↳ `id` | number | Unique id of the odd | +| ↳ `fixture_id` | number | Fixture the odd belongs to | +| ↳ `market_id` | number | Market the odd belongs to | +| ↳ `bookmaker_id` | number | Bookmaker offering the odd | +| ↳ `label` | string | Outcome label | +| ↳ `value` | string | Decimal odds value | +| ↳ `name` | string | Outcome name | +| ↳ `sort_order` | number | Sort order of the odd | +| ↳ `market_description` | string | Description of the market | +| ↳ `probability` | string | Implied probability \(e.g. 29.85%\) | +| ↳ `dp3` | string | Decimal odds to 3 decimal places | +| ↳ `fractional` | string | Fractional odds | +| ↳ `american` | string | American/moneyline odds | +| ↳ `stopped` | boolean | Whether the odd is stopped | +| ↳ `total` | string | Total line for over/under markets | +| ↳ `handicap` | string | Handicap line for handicap markets | +| ↳ `created_at` | string | Timestamp the odd was created \(UTC\) | +| ↳ `updated_at` | string | Timestamp the odd was last updated \(UTC\) | +| ↳ `latest_bookmaker_update` | string | Bookmaker's own last-update timestamp \(UTC\) | + +### `sportmonks_odds_search_bookmakers` + +Search for bookmakers by name from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The bookmaker name to search for \(e.g. bet365\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `bookmakers` | array | Array of bookmaker objects matching the search query | +| ↳ `id` | number | Unique id of the bookmaker | +| ↳ `name` | string | Name of the bookmaker | +| ↳ `logo` | string | Logo of the bookmaker | + +### `sportmonks_odds_search_markets` + +Search for betting markets by name from the Sportmonks Odds API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The market name to search for \(e.g. Over/Under\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `markets` | array | Array of market objects matching the search query | +| ↳ `id` | number | Unique id of the market | +| ↳ `name` | string | Name of the market | +| ↳ `developer_name` | string | Developer \(machine-readable\) name of the market | + +### `sportmonks_core_get_cities` + +Retrieve all cities from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. region\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `cities` | array | Array of city objects | +| ↳ `id` | number | Unique id of the city | +| ↳ `country_id` | number | Country of the city | +| ↳ `region_id` | number | Region id of the city | +| ↳ `name` | string | Name of the city | +| ↳ `latitude` | string | Latitude of the city | +| ↳ `longitude` | string | Longitude of the city | +| ↳ `geonameid` | number | Official geonameid of the city | + +### `sportmonks_core_get_city` + +Retrieve a single city by its ID from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `cityId` | string | Yes | The unique id of the city | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. region\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `city` | object | The requested city object | +| ↳ `id` | number | Unique id of the city | +| ↳ `country_id` | number | Country of the city | +| ↳ `region_id` | number | Region id of the city | +| ↳ `name` | string | Name of the city | +| ↳ `latitude` | string | Latitude of the city | +| ↳ `longitude` | string | Longitude of the city | +| ↳ `geonameid` | number | Official geonameid of the city | + +### `sportmonks_core_get_continent` + +Retrieve a single continent by its ID from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `continentId` | string | Yes | The unique id of the continent | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. countries\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `continent` | object | The requested continent object | +| ↳ `id` | number | Unique id of the continent | +| ↳ `name` | string | Name of the continent | +| ↳ `code` | string | Short code of the continent | + +### `sportmonks_core_get_continents` + +Retrieve all continents from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. countries\) | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `continents` | array | Array of continent objects | +| ↳ `id` | number | Unique id of the continent | +| ↳ `name` | string | Name of the continent | +| ↳ `code` | string | Short code of the continent | + +### `sportmonks_core_get_countries` + +Retrieve all countries from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. continent;regions\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `countries` | array | Array of country objects | +| ↳ `id` | number | Unique id of the country | +| ↳ `continent_id` | number | Continent of the country | +| ↳ `name` | string | Name of the country | +| ↳ `official_name` | string | Official name of the country | +| ↳ `fifa_name` | string | Official FIFA short code name | +| ↳ `iso2` | string | Two letter country code | +| ↳ `iso3` | string | Three letter country code | +| ↳ `latitude` | string | Latitude position of the country | +| ↳ `longitude` | string | Longitude position of the country | +| ↳ `geonameid` | number | Official geonameid | +| ↳ `borders` | array | Neighbouring countries \(ISO3 codes\) | +| ↳ `image_path` | string | Image path to the country flag | + +### `sportmonks_core_get_country` + +Retrieve a single country by its ID from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `countryId` | string | Yes | The unique id of the country | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. continent;regions\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `country` | object | The requested country object | +| ↳ `id` | number | Unique id of the country | +| ↳ `continent_id` | number | Continent of the country | +| ↳ `name` | string | Name of the country | +| ↳ `official_name` | string | Official name of the country | +| ↳ `fifa_name` | string | Official FIFA short code name | +| ↳ `iso2` | string | Two letter country code | +| ↳ `iso3` | string | Three letter country code | +| ↳ `latitude` | string | Latitude position of the country | +| ↳ `longitude` | string | Longitude position of the country | +| ↳ `geonameid` | number | Official geonameid | +| ↳ `borders` | array | Neighbouring countries \(ISO3 codes\) | +| ↳ `image_path` | string | Image path to the country flag | + +### `sportmonks_core_get_entity_filters` + +Retrieve all available filters grouped per entity from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entityFilters` | json | Map of entity name to its available filter names, e.g. \{fixture: \["fixtureLeagues", "fixtureSeasons"\], event: \["eventTypes"\]\} | + +### `sportmonks_core_get_my_usage` + +Retrieve your Sportmonks API usage aggregated per 5 minutes + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `usage` | array | Array of API usage records aggregated per 5-minute period | +| ↳ `id` | number | Identifier of the usage record | +| ↳ `endpoint` | string | Identifier of the requested endpoint | +| ↳ `count` | number | Total calls for the given timeframe | +| ↳ `entity` | string | The entity the rate limit applies on | +| ↳ `remaining_requests` | number | Amount of requests remaining for the entity in the hourly rate limit | +| ↳ `period_start` | number | Timestamp representing the aggregation start time | +| ↳ `period_end` | number | Timestamp representing the aggregation end time | + +### `sportmonks_core_get_region` + +Retrieve a single region by its ID from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `regionId` | string | Yes | The unique id of the region | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;cities\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `region` | object | The requested region object | +| ↳ `id` | number | Unique id of the region | +| ↳ `country_id` | number | Country of the region | +| ↳ `name` | string | Name of the region | + +### `sportmonks_core_get_regions` + +Retrieve all regions from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;cities\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `regions` | array | Array of region objects | +| ↳ `id` | number | Unique id of the region | +| ↳ `country_id` | number | Country of the region | +| ↳ `name` | string | Name of the region | + +### `sportmonks_core_get_timezones` + +Retrieve all supported time zones (IANA names) from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timezones` | array | Array of supported IANA time zone names \(e.g. Europe/London\) | + +### `sportmonks_core_get_type` + +Retrieve a single type by its ID from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `typeId` | string | Yes | The unique id of the type | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | object | The requested type object | +| ↳ `id` | number | Unique id of the type | +| ↳ `parent_id` | number | Parent type of the type | +| ↳ `name` | string | Name of the type | +| ↳ `code` | string | Code of the type | +| ↳ `developer_name` | string | Developer name of the type | +| ↳ `group` | string | Group the type falls under | +| ↳ `description` | string | Description of the type | + +### `sportmonks_core_get_type_by_entity` + +Retrieve the available types grouped per entity from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `typesByEntity` | json | Map of entity name to its available types, e.g. \{CoachStatisticDetail: \{updated_at, types: \[\{id, name, code, developer_name, model_type, stat_group\}\]\}\} | + +### `sportmonks_core_get_types` + +Retrieve all types (reference data describing events, statistics, positions, etc.) from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `types` | array | Array of type objects | +| ↳ `id` | number | Unique id of the type | +| ↳ `parent_id` | number | Parent type of the type | +| ↳ `name` | string | Name of the type | +| ↳ `code` | string | Code of the type | +| ↳ `developer_name` | string | Developer name of the type | +| ↳ `group` | string | Group the type falls under | +| ↳ `description` | string | Description of the type | + +### `sportmonks_core_search_cities` + +Search for cities by name from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The city name to search for \(e.g. London\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. region\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `cities` | array | Array of city objects matching the search query | +| ↳ `id` | number | Unique id of the city | +| ↳ `country_id` | number | Country of the city | +| ↳ `region_id` | number | Region id of the city | +| ↳ `name` | string | Name of the city | +| ↳ `latitude` | string | Latitude of the city | +| ↳ `longitude` | string | Longitude of the city | +| ↳ `geonameid` | number | Official geonameid of the city | + +### `sportmonks_core_search_countries` + +Search for countries by name from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The country name to search for \(e.g. Brazil\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. continent;regions\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `countries` | array | Array of country objects matching the search query | +| ↳ `id` | number | Unique id of the country | +| ↳ `continent_id` | number | Continent of the country | +| ↳ `name` | string | Name of the country | +| ↳ `official_name` | string | Official name of the country | +| ↳ `fifa_name` | string | Official FIFA short code name | +| ↳ `iso2` | string | Two letter country code | +| ↳ `iso3` | string | Three letter country code | +| ↳ `latitude` | string | Latitude position of the country | +| ↳ `longitude` | string | Longitude position of the country | +| ↳ `geonameid` | number | Official geonameid | +| ↳ `borders` | array | Neighbouring countries \(ISO3 codes\) | +| ↳ `image_path` | string | Image path to the country flag | + +### `sportmonks_core_search_regions` + +Search for regions by name from the Sportmonks Core API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sportmonks API token | +| `query` | string | Yes | The region name to search for \(e.g. Utrecht\) | +| `include` | string | No | Semicolon-separated relations to enrich the response \(e.g. country;cities\) | +| `filters` | string | No | Filters to apply | +| `per_page` | string | No | Number of results per page \(max 50, default 25\) | +| `page` | string | No | Page number to retrieve | +| `order` | string | No | Order direction \(asc or desc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `regions` | array | Array of region objects matching the search query | +| ↳ `id` | number | Unique id of the region | +| ↳ `country_id` | number | Country of the region | +| ↳ `name` | string | Name of the region | + + diff --git a/apps/docs/content/docs/en/platform/enterprise/access-control.mdx b/apps/docs/content/docs/en/platform/enterprise/access-control.mdx index 5b54a40cb5c..2f707de9823 100644 --- a/apps/docs/content/docs/en/platform/enterprise/access-control.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/access-control.mdx @@ -7,22 +7,22 @@ import { Callout } from 'fumadocs-ui/components/callout' import { FAQ } from '@/components/ui/faq' import { Image } from '@/components/ui/image' -Access Control lets organization admins define permission groups that restrict what each set of organization members can do — which AI model providers they can use, which workflow blocks they can place, and which platform features are visible to them. Permission groups are scoped to the **organization** and can govern either every workspace in the organization or a specific subset of workspaces. A user can belong to multiple groups, but is governed by exactly one group in any given workspace. Restrictions are enforced both in the workflow executor and in Mothership, based on the organization that owns the workflow's workspace. +Access Control lets organization admins define permission groups that restrict what each set of users can do — which AI model providers they can use, which workflow blocks they can place, and which platform features are visible to them. Permission groups are scoped to the **organization**. The organization's **default group** governs everyone org-wide; every other group targets a **specific set of workspaces** and, by default, governs **all members of those workspaces** (including external members) — or only specific members once you add them. A user is governed by exactly one group in any given workspace. Restrictions are enforced both in the workflow executor and in Mothership, based on the organization that owns the workflow's workspace. --- ## How it works -Access control is built around **permission groups**. Each group belongs to a specific organization and has a name, an optional description, a **workspace scope** (all workspaces or a specific subset), and a configuration that defines what its members can and cannot do. A user can belong to multiple permission groups, but at most one group governs them in any given workspace. Personal workspaces that do not belong to an organization have no permission groups. +Access control is built around **permission groups**. Each group belongs to a specific organization and has a name, an optional description, a **workspace scope**, an optional **member** list, and a configuration that defines what its members can and cannot do. The organization's single **default group** is org-wide; every other group targets a **specific set of workspaces**. A non-default group with **no members** governs **all members** of its workspaces (including external members); adding members narrows it to only those people. Personal workspaces that do not belong to an organization have no permission groups. -Sim resolves the governing group for a user in a workspace deterministically, with **specific-over-all precedence**: +Sim resolves the governing group for a user in a workspace deterministically: -1. a group the user belongs to that **specifically targets that workspace** takes precedence; otherwise -2. a group they belong to that applies to **all workspaces** applies; otherwise +1. a non-default group targeting that workspace that the user is an **explicit member** of takes precedence; otherwise +2. a non-default group targeting that workspace that has **no members** (so it governs all members of the workspace, including external members) applies; otherwise 3. the organization's **default group** applies (if one is set); otherwise 4. no restrictions apply. -Because a user's specific-scope groups may not overlap on a workspace, and a user may belong to at most one all-workspaces group, the governing group is always unambiguous. +Assignment-time checks keep this unambiguous: a workspace has at most one all-members group, and a user is an explicit member of at most one group per workspace. When a user runs a workflow or uses Mothership, Sim reads the resolved group's configuration and applies it: @@ -41,11 +41,15 @@ Go to **Settings → Enterprise → Access Control** from any workspace in your ### 2. Create a permission group -Click **+ Create** and enter a name (required) and optional description. Choose whether the group applies to **all workspaces** in the organization or only a **specific set** of workspaces — when specific, pick the workspaces from the multi-select. You can also mark the group as the **organization default group** — when set, it governs every organization member who is not explicitly assigned to another group, as well as external workspace members operating in the organization's workspaces. Only one group per organization can be the default at a time, and the default group always applies to all workspaces. +Click **+ Create** and enter a name (required) and optional description. A group either targets a **specific set of workspaces** (pick them from the multi-select) or is the **organization default group**. A workspace-scoped group applies to **all members** of its workspaces by default, including external members — you can narrow it to specific people later. Marking the group as the **default** makes it govern every workspace in the organization and every member not covered by another group (including external members); only one group per organization can be the default, and the default always applies to all workspaces. ### 3. Configure permissions -Click **Details** on a group, then open **Configure Permissions**. There are three tabs. +Click **Details** on a group, then open **Configure Permissions**. Non-default groups have a **Members** tab plus three restriction tabs; the default group has only the restriction tabs. + +#### Members + +A workspace-scoped group with **no members** applies to everyone in its workspaces (including external members). Add members here — searching your organization by name or email — to restrict the group to only those people; removing every member returns it to governing everyone. The default group ignores members and has no Members tab. #### Model Providers @@ -135,13 +139,15 @@ Controls visibility of platform features and modules. |---------|-------------------| | Invitations | Disables the ability to invite new members to the workspace | -### 4. Add members +### 4. Choose who it applies to + +A workspace-scoped group applies to **all members of its workspaces by default** — including external members. To restrict it to specific people instead, open **Configure Permissions → Members** and add members by searching your organization by name or email. Removing every member returns the group to governing everyone in its workspaces. -Open the group's **Details** view and add members by searching for users by name or email. The member picker lists your organization's members. A user can belong to multiple groups, but only one group can govern them in any given workspace — so adding a user to a group is rejected when it would conflict with another of their groups on a workspace (two all-workspaces groups, or two specific groups that share a workspace). In bulk adds, conflicting users are skipped rather than added. +A user is governed by one group per workspace, so adding a user is rejected when it would conflict with another of their groups on a shared workspace (skipped rather than added in bulk). The default group ignores members entirely — it always governs everyone not covered by a workspace group. -You can also change a group's workspace scope at any time from the **Workspaces** row in the Details view. +Manage which workspaces a group governs from the **Workspaces** list in the group's **Details** view (Add and Remove). A non-default group must always target at least one workspace. -External workspace members (people who have access to a workspace but belong to a different organization) are not assigned to groups individually. They are governed by the organization's **default group** when one is set; otherwise no restrictions apply to them. +External workspace members (people who have access to a workspace but belong to a different organization) can't be added as named members, but a workspace-scoped group with no members — and the organization default group — still governs them. --- @@ -169,10 +175,11 @@ When a user opens Mothership, their permission group is read before any block or ## User membership rules - A user can belong to **multiple** permission groups, but **at most one** group governs them in any given workspace. -- For a given workspace, a group that **specifically targets that workspace** takes precedence over a group that applies to **all workspaces**, which takes precedence over the organization's **default group**. -- A user's specific-scope groups may not overlap on a workspace, and a user may belong to at most one all-workspaces group. Adding a user in a way that would violate this is rejected (single add) or skipped (bulk add) — memberships are never silently moved between groups. -- Users not governed by any group fall under the organization's **default group** if one is set; otherwise no restrictions are applied to them. -- Only one group per organization can be marked as the **default group**, and it always applies to all workspaces. The default also governs external workspace members operating in the organization's workspaces. +- For a given workspace, a non-default group the user is an **explicit member** of takes precedence over a non-default **all-members** group (one with no members) targeting that workspace, which takes precedence over the organization's **default group**. +- A workspace has **at most one all-members group**, and a user is an explicit member of **at most one** group per workspace. Adding a user, adding a workspace, or removing a group's last member is rejected when it would violate this — memberships and scopes are never silently moved. +- A workspace-scoped group with **no members** governs everyone in its workspaces (including external members); add members to narrow it to specific people. +- Users not covered by any workspace group fall under the organization's **default group** if one is set; otherwise no restrictions are applied to them. +- Only one group per organization can be the **default group**; it always applies to all workspaces, ignores members, and also governs external workspace members. - Personal or grandfathered workspaces that do not belong to an organization have no permission groups. --- @@ -188,15 +195,19 @@ When a user opens Mothership, their permission group is read before any block or }, { question: "Can a user be in multiple permission groups?", - answer: "Yes. A user can belong to multiple permission groups, but only one group governs them in any given workspace. A group that specifically targets a workspace takes precedence over an all-workspaces group, which takes precedence over the organization's default group. A user's specific-scope groups may not overlap on a workspace, and a user may belong to at most one all-workspaces group." + answer: "Yes. A user can belong to multiple permission groups, but only one governs them in any given workspace. A group they're an explicit member of takes precedence over an all-members group (one with no members) on that workspace, which takes precedence over the organization's default group. A workspace has at most one all-members group, and a user is an explicit member of at most one group per workspace." + }, + { + question: "Does a permission group apply to everyone in its workspaces, or specific people?", + answer: "By default, a workspace-scoped group applies to all members of its workspaces, including external members. To restrict it to specific people, open Configure Permissions → Members and add them; the group then governs only those members. Removing every member returns it to governing everyone in its workspaces." }, { question: "Can a permission group apply to only some workspaces?", - answer: "Yes. When creating or editing a group, choose 'Specific workspaces' and select the workspaces it should govern. A specific-scope group governs its members only in those workspaces; elsewhere those members fall back to their all-workspaces group (if any) or the organization default. The default group always applies to all workspaces." + answer: "Every non-default group targets a specific set of workspaces — choose them when you create the group or from the Workspaces list in its Details view. Only the organization default group is org-wide." }, { question: "What governs a user who has no permission group assigned?", - answer: "If the organization has a default group, it governs every member (and external workspace member) who is not explicitly assigned to another group. If no default group is set, those users have no restrictions — all blocks, model providers, and platform features are available to them." + answer: "In a workspace targeted by an all-members group (one with no members), that group governs them. Otherwise the organization's default group governs them if one is set. If neither applies, they have no restrictions — all blocks, model providers, and platform features are available." }, { question: "Does Mothership respect the same restrictions as the executor?", @@ -204,11 +215,11 @@ When a user opens Mothership, their permission group is read before any block or }, { question: "Can I apply different restrictions to different people or workspaces?", - answer: "Yes. Assign different sets of users to different permission groups to give them different restrictions, and scope each group to all workspaces or a specific subset to vary restrictions per workspace. A user can be in an all-workspaces group for a baseline plus a specific-workspace group that overrides it in select workspaces. Who can access a given workspace is still controlled separately by workspace invitations and permissions." + answer: "Yes. Scope each group to the workspaces it should govern, and either let it apply to everyone there or add members to target specific people. A workspace can have one all-members group plus additional groups that target specific people, who are governed by their own group instead. Who can access a given workspace is still controlled separately by workspace invitations and permissions." }, { question: "What is the default group?", - answer: "The default group is the single group per organization that governs everyone not explicitly assigned to another group, including external workspace members operating in the organization's workspaces. Only one group per organization can be the default at a time. If no default is set, ungrouped users have no restrictions." + answer: "The default group is the single org-wide group that governs everyone not covered by a workspace group, including external workspace members. It always applies to all workspaces and ignores members. Only one per organization; if none is set, ungrouped users have no restrictions." } ]} /> diff --git a/apps/docs/content/docs/en/platform/permissions.mdx b/apps/docs/content/docs/en/platform/permissions.mdx index 7dc4980ace3..97a7412cc64 100644 --- a/apps/docs/content/docs/en/platform/permissions.mdx +++ b/apps/docs/content/docs/en/platform/permissions.mdx @@ -1,13 +1,36 @@ --- title: Roles and permissions -description: Organization roles, workspace permission levels, and who can do what. +description: How organization, workspace, and credential roles work together — and how they inherit. pageType: reference --- import { Callout } from 'fumadocs-ui/components/callout' import { Video } from '@/components/ui/video' -Access in Sim has two layers: **organization roles** (Owner, Admin, Member) govern the organization itself, and **workspace permissions** (Read, Write, Admin) govern what each member can do inside a workspace. +Access in Sim is organized into three nested levels — your **organization**, the **workspaces** inside it, and the **credentials** (connected accounts and secrets) inside those. Each level has its own roles, and roles **inherit downward**: an admin at one level is automatically an admin at the level below. Inheritance only ever *adds* access — it never takes access away from someone who already has it. + +## How roles inherit + +Each level has its own set of roles: + +| Level | Roles | +|-------|-------| +| **Organization** | Owner, Admin, Member | +| **Workspace** | Read, Write, Admin | +| **Credentials** (shared connections and secrets) | Member, Admin | + +Higher roles flow down automatically: + +- An organization **Owner or Admin** is automatically an **Admin of every workspace** in the organization — no per-workspace invite required. +- A workspace **Admin** is automatically an **Admin of every shared credential** in that workspace — OAuth connections, service accounts, and workspace secrets. + +Put together, an organization Owner or Admin can administer every workspace and every shared credential in the organization, top to bottom. + +Inherited roles are **automatic and locked**. In member lists they show greyed out with a short tooltip saying where the role comes from (for example, *"Organization admins are automatically workspace admins"*), and they can't be lowered there — you change them at the level they come from. + + +**Personal secrets are the one exception.** A user's personal environment variables stay private to them and are never shared or inherited — not by workspace Admins, not by organization Owners or Admins, not by anyone. + ## Workspaces and Organizations @@ -96,32 +119,20 @@ Here's a detailed breakdown of what users can do with each permission level: - Invite new users to the workspace with any permission level - Remove users from the workspace - Manage workspace settings and integrations -- Configure external tool connections +- Administer every shared credential in the workspace (OAuth connections, service accounts, and workspace secrets) - Delete workflows created by other users +- Delete the workspace **What they cannot do:** -- Delete the workspace (only the workspace owner can do this) -- Remove the workspace owner from the workspace +- Change a role that's inherited from a higher level — an organization admin's workspace role, or the owner's, is locked and managed where it comes from --- -## Workspace Owner vs Admin - -Every workspace has one **Owner** (the person who created it) plus any number of **Admins**. +## Workspace Owner -### Workspace Owner -- Has all Admin permissions -- Can delete the workspace -- Cannot be removed from the workspace -- Can transfer ownership to another user +Every workspace has one **Owner** — usually the person who created it. The Owner is simply an Admin whose role is fixed: in the member list it shows as a locked **Admin** (tooltip *"Workspace owner"*), so it can't be lowered. An Owner has no abilities a regular Admin lacks. -### Workspace Admin -- Can do everything except delete the workspace or remove the owner -- Can be removed from the workspace by the owner or other admins - - - For shared (organization) workspaces, the organization's Owner and Admins are treated as Admins of every workspace in the organization, even without an explicit per-workspace invite. - +Any Admin — whether invited directly or an admin by way of their organization role — can manage members and settings and delete the workspace. On a shared workspace an Admin can also remove the Owner; ownership then passes to the organization's owner, so the workspace always has one. (The organization's owner is the one account that can't be removed this way — they're the final fallback.) On your personal workspace you are the Owner and can't be removed. --- @@ -146,15 +157,35 @@ Every workspace has one **Owner** (the person who created it) plus any number of Users can create two types of environment variables: ### Personal Environment Variables -- Only visible to the individual user +- Only visible to the individual user, and never shared or inherited — not even by workspace or organization admins - Available in all workflows they run - Managed in **Settings**, then go to **Secrets** ### Workspace Environment Variables -- **Read permission**: Can see variable names and values -- **Write/Admin permission**: Can add, edit, and delete variables -- Available to all workspace members -- If a personal variable has the same name as a workspace variable, the personal one takes priority +- **Read**: see variable names (the values stay hidden unless you're an admin of that secret) +- **Write**: add new variables, and edit or delete ones you created +- **Admin**: add, edit, delete, and view the values of any workspace variable +- Workspace variables are a kind of workspace credential, so they follow the [Credential Access](#credential-access) rules below — workspace Admins are admins of all of them +- Available to all workspace members. If a workspace variable and a personal variable share the same name, the **workspace** value wins when a workflow runs + +--- + +## Credential Access + +Workspace credentials — OAuth connections, service accounts, and workspace environment variables — have two roles of their own: + +- **Credential Member**: can use the credential in workflows. +- **Credential Admin**: can use it and also edit, delete, and share it. + +These roles follow your workspace role: + +- **Workspace Admins are automatically Credential Admins** of every shared credential in the workspace (OAuth connections, service accounts, and workspace environment variables). Because organization Owners and Admins are workspace Admins everywhere, they are Credential Admins too. These automatic roles are fixed — they show greyed out with a tooltip in the credential's member list and cannot be changed. +- **Read and Write members are Credential Members** by default — they can use shared credentials but cannot edit, delete, or share them unless someone makes them a Credential Admin (you are always an admin of credentials you create). +- **Personal environment variables** are the exception: they stay private to their owner and are never shared with workspace admins. + + + A Credential Admin can both use and manage a credential, so a workspace Admin can run workflows that use any shared OAuth connection in the workspace — including one another member added. + --- @@ -173,7 +204,7 @@ An organization has three roles: **Owner**, **Admin**, and **Member**. - Invite and remove team members from the organization - Create new shared workspaces under the organization - Manage billing, seat count, and subscription settings -- Access all shared workspaces within the organization as a workspace Admin +- Access every shared workspace in the organization as a workspace Admin automatically (no per-workspace invite), including administering the credentials inside them - Promote members to Admin or demote Admins to Member @@ -192,8 +223,9 @@ import { FAQ } from '@/components/ui/faq' { question: "What is the difference between organization roles and workspace permissions?", answer: "Organization roles (Owner, Admin, or Member) control who can manage the organization itself, including inviting people, creating shared workspaces, and handling billing. Workspace permissions (Read, Write, Admin) control what a user can do within a specific workspace, such as viewing, editing, or managing workflows. Internal members need both an organization role and a workspace permission to work within a shared workspace. External workspace members do not have an organization role in your org; they only have workspace-level access." }, { question: "What happens to my shared workspaces if I cancel or downgrade my Team plan?", answer: "Existing shared workspaces remain accessible to current members, but new invitations are disabled until you upgrade back to a Team or Enterprise plan. No workspaces or members are deleted — the organization is simply dormant until billing is re-enabled." }, { question: "Can I restrict which integrations or model providers a team member can use?", answer: "Yes, on Enterprise-entitled organizations. Any organization owner or admin can create permission groups with fine-grained controls, including restricting allowed integrations and allowed model providers to specific lists. You can also disable access to MCP tools, custom tools, skills, and various platform features like the knowledge base, API keys, or Copilot on a per-group basis. Permission groups are scoped to the organization and can govern either all workspaces or a specific subset — a user can belong to multiple groups but is governed by exactly one group in any given workspace." }, - { question: "What happens when a personal environment variable has the same name as a workspace variable?", answer: "The personal environment variable takes priority. When a workflow runs, if both a personal and workspace variable share the same name, the personal value is used. This allows individual users to override shared workspace configuration when needed." }, - { question: "Can an Admin remove the workspace owner?", answer: "No. The workspace owner cannot be removed from the workspace by anyone. Only the workspace owner can delete the workspace or transfer ownership to another user. Admins can do everything else, including inviting and removing other users and managing workspace settings." }, - { question: "What are permission groups and how do they work?", answer: "Permission groups are an Enterprise access control feature that lets organization owners and admins define granular restrictions beyond the standard Read/Write/Admin roles. Groups are scoped to the organization and can govern either all workspaces or a specific subset. A user can belong to multiple groups, but at most one governs them in any given workspace: a workspace-specific group takes precedence over an all-workspaces group, which takes precedence over the organization's default group. A permission group can hide UI sections (like trace spans, knowledge base, API keys, or deployment options), disable features (MCP tools, custom tools, skills, invitations), and restrict which integrations and model providers its members can access. Members are assigned manually, and an organization can designate one group as the default (always all-workspaces) that governs everyone not explicitly assigned — including external workspace members. Execution-time enforcement is based on the organization that owns the workflow's workspace, not the user's current UI context." }, + { question: "What happens when a personal environment variable has the same name as a workspace variable?", answer: "The workspace variable wins. When a workflow runs, the resolver checks workspace variables first and falls back to a personal variable only when no workspace variable shares that name. This keeps shared, team-managed values authoritative in production workflows." }, + { question: "Can an Admin remove the workspace owner?", answer: "On a shared (organization) workspace, yes — any Admin can remove the workspace Owner, and ownership passes to the organization's owner so the workspace always has one. The organization's owner is the single account that can't be removed this way, since they're the final fallback. On your personal workspace you are the Owner and can't remove yourself. The Owner is not a higher permission tier than Admin: every Admin — including those who inherit the role from their organization — can manage members and settings and delete the workspace." }, + { question: "Who can manage a workspace's credentials and secrets?", answer: "Workspace Admins are automatically Credential Admins of the workspace's shared credentials — OAuth connections, service accounts, and workspace environment variables — so they can use, edit, delete, and share them, and run workflows that rely on them. Organization Owners and Admins get this too because they are workspace Admins everywhere. Read and Write members get use-only access to shared credentials unless they are explicitly made a Credential Admin. Personal environment variables are never shared; they stay private to their owner." }, + { question: "What are permission groups and how do they work?", answer: "Permission groups are an Enterprise access control feature that lets organization owners and admins define granular restrictions beyond the standard Read/Write/Admin roles. The organization's default group is org-wide; every other group targets specific workspaces and, by default, governs all members of those workspaces (including external members) — add members to restrict it to specific people. A user is governed by one group per workspace: a group they're an explicit member of takes precedence over an all-members group (one with no members) on that workspace, which takes precedence over the organization's default group. A permission group can hide UI sections (like trace spans, knowledge base, API keys, or deployment options), disable features (MCP tools, custom tools, skills, invitations), and restrict which integrations and model providers its members can access. Only one group per organization can be the default; it ignores members and governs everyone not covered by a workspace group, including external members. Restrictions are enforced based on the organization that owns the workflow's workspace, not on which workspace you're currently viewing." }, { question: "How should I set up permissions for a new team member?", answer: "Start with the lowest permission level they need. Invite teammates to the organization as Members, then add them to the relevant workspace with Read permission if they only need visibility, Write if they need to create and run workflows, or Admin if they need to manage the workspace and its users. For clients, partners, or users who already belong to another Sim organization, use external workspace access so they can collaborate without joining your organization or consuming a seat." }, ]} /> \ No newline at end of file diff --git a/apps/docs/content/docs/en/workflows/blocks/webhook.mdx b/apps/docs/content/docs/en/workflows/blocks/webhook.mdx index 845347e340c..374fd03b913 100644 --- a/apps/docs/content/docs/en/workflows/blocks/webhook.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/webhook.mdx @@ -1,6 +1,6 @@ --- -title: Webhook -description: The Webhook block sends an HTTP POST to an external endpoint, with automatic headers and optional signing. +title: Outgoing Webhook +description: The Outgoing Webhook block sends an HTTP POST to an external endpoint, with automatic headers and optional signing. pageType: reference --- @@ -8,7 +8,7 @@ import { Callout } from 'fumadocs-ui/components/callout' import { BlockPreview, WorkflowPreview, WEBHOOK_NOTIFY_WORKFLOW, WEBHOOK_TRIGGER_WORKFLOW } from '@/components/workflow-preview' import { FAQ } from '@/components/ui/faq' -The Webhook block sends HTTP POST requests to external webhook endpoints with automatic webhook headers and optional HMAC signing. +The Outgoing Webhook block sends HTTP POST requests to external webhook endpoints with automatic webhook headers and optional HMAC signing. @@ -77,16 +77,16 @@ Format the result, then POST it to a Slack, Discord, or custom endpoint. -When the Condition passes, the Webhook starts a process in another system. +When the Condition passes, the Outgoing Webhook starts a process in another system. -The Webhook block always uses POST. For other HTTP methods or more control, use the [API block](/workflows/blocks/api). +The Outgoing Webhook block always uses POST. For other HTTP methods or more control, use the [API block](/workflows/blocks/api). diff --git a/apps/docs/content/docs/en/workflows/triggers/sim.mdx b/apps/docs/content/docs/en/workflows/triggers/sim.mdx index 08edb72af8d..4f77b80e2fd 100644 --- a/apps/docs/content/docs/en/workflows/triggers/sim.mdx +++ b/apps/docs/content/docs/en/workflows/triggers/sim.mdx @@ -1,16 +1,16 @@ --- -title: Sim +title: Sim Workspace Events --- import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { FAQ } from '@/components/ui/faq' -The Sim trigger runs a workflow when events happen in your workspace: another workflow's run fails or succeeds, a workflow is deployed, or an alert condition like a latency spike or cost threshold is met. Use it to build side-effect workflows — alerting, escalation, auto-remediation — composed from any blocks (Slack, email, webhooks, custom logic). +The Sim Workspace Events trigger runs a workflow when events happen in your workspace: another workflow's run fails or succeeds, a workflow is deployed, or an alert condition like a latency spike or cost threshold is met. Use it to build side-effect workflows — alerting, escalation, auto-remediation — composed from any blocks (Slack, email, webhooks, custom logic). ## Events -Pick one event per Sim trigger block: +Pick one event per Sim Workspace Events trigger block: **Plain events** — fire on every occurrence: @@ -68,8 +68,8 @@ All events include `event`, `timestamp`, `workflowId`, and `workflowName` (the s ## Behavior
    -
  • The workflow containing the Sim trigger must be deployed for events to fire.
  • -
  • Runs started by a Sim trigger never emit workspace events, so side-effect workflows cannot chain or loop.
  • +
  • The workflow containing the Sim Workspace Events trigger must be deployed for events to fire.
  • +
  • Runs started by a Sim Workspace Events trigger never emit workspace events, so side-effect workflows cannot chain or loop.
  • Alert conditions fire at most once per cooldown window (one hour, or the inactivity window for No Activity).
  • Event delivery is fire-and-forget: side-effect runs are billed like any other run and are subject to workspace rate limits.
@@ -80,8 +80,8 @@ All events include `event`, `timestamp`, `workflowId`, and `workflowName` (the s diff --git a/apps/realtime/package.json b/apps/realtime/package.json index 17f412773e1..99867ef852d 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -24,10 +24,10 @@ "@sim/auth": "workspace:*", "@sim/db": "workspace:*", "@sim/logger": "workspace:*", + "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", "@sim/security": "workspace:*", "@sim/utils": "workspace:*", - "@sim/workflow-authz": "workspace:*", "@sim/workflow-persistence": "workspace:*", "@sim/workflow-types": "workspace:*", "@socket.io/redis-adapter": "8.3.0", diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index c5474a5f6f8..ac0072c959d 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -8,6 +8,7 @@ import { workflowSubflows, } from '@sim/db' import { createLogger } from '@sim/logger' +import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' import { BLOCK_OPERATIONS, BLOCKS_OPERATIONS, @@ -20,7 +21,6 @@ import { WORKFLOW_OPERATIONS, } from '@sim/realtime-protocol/constants' import { randomFloat } from '@sim/utils/random' -import { getActiveWorkflowContext } from '@sim/workflow-authz' import { loadWorkflowFromNormalizedTablesRaw } from '@sim/workflow-persistence/load' import { mergeSubBlockValues } from '@sim/workflow-persistence/subblocks' import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' diff --git a/apps/realtime/src/handlers/operations.ts b/apps/realtime/src/handlers/operations.ts index 49d5bbcd0b2..eef51847718 100644 --- a/apps/realtime/src/handlers/operations.ts +++ b/apps/realtime/src/handlers/operations.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { BLOCK_OPERATIONS, BLOCKS_OPERATIONS, @@ -11,7 +12,6 @@ import { import { WorkflowOperationSchema } from '@sim/realtime-protocol/schemas' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { ZodError } from 'zod' import { persistWorkflowOperation } from '@/database/operations' import type { AuthenticatedSocket } from '@/middleware/auth' diff --git a/apps/realtime/src/handlers/subblocks.ts b/apps/realtime/src/handlers/subblocks.ts index 0295aff458c..b2f94b6fb98 100644 --- a/apps/realtime/src/handlers/subblocks.ts +++ b/apps/realtime/src/handlers/subblocks.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' import { workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants' import { getErrorMessage } from '@sim/utils/errors' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' import { and, eq } from 'drizzle-orm' import type { AuthenticatedSocket } from '@/middleware/auth' diff --git a/apps/realtime/src/handlers/variables.ts b/apps/realtime/src/handlers/variables.ts index f9b1c1f0c68..98dc3a5b7af 100644 --- a/apps/realtime/src/handlers/variables.ts +++ b/apps/realtime/src/handlers/variables.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { VARIABLE_OPERATIONS } from '@sim/realtime-protocol/constants' import { getErrorMessage } from '@sim/utils/errors' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import type { AuthenticatedSocket } from '@/middleware/auth' import { checkWorkflowOperationPermission } from '@/middleware/permissions' diff --git a/apps/realtime/src/middleware/permissions.test.ts b/apps/realtime/src/middleware/permissions.test.ts index 554ba8355fd..c1078b8c0c0 100644 --- a/apps/realtime/src/middleware/permissions.test.ts +++ b/apps/realtime/src/middleware/permissions.test.ts @@ -19,7 +19,7 @@ const { mockAuthorize } = vi.hoisted(() => ({ mockAuthorize: vi.fn(), })) -vi.mock('@sim/workflow-authz', () => ({ +vi.mock('@sim/platform-authz/workflow', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorize, })) diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index 23069ff51de..00fc5c9580f 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { BLOCK_OPERATIONS, BLOCKS_OPERATIONS, @@ -11,7 +12,6 @@ import { VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, } from '@sim/realtime-protocol/constants' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' const logger = createLogger('SocketPermissions') diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md index a766fb697d5..6c52c2df02d 100644 --- a/apps/sim/AGENTS.md +++ b/apps/sim/AGENTS.md @@ -29,7 +29,7 @@ apps/ └── realtime/ # Bun Socket.IO server (collaborative canvas) packages/ # @sim/* — audit, auth, db, logger, realtime-protocol, - # security, tsconfig, utils, workflow-authz, + # security, tsconfig, utils, platform-authz, # workflow-persistence, workflow-types ``` diff --git a/apps/sim/app/(landing)/components/pricing/pricing.tsx b/apps/sim/app/(landing)/components/pricing/pricing.tsx index d989f714167..b3d417ffba0 100644 --- a/apps/sim/app/(landing)/components/pricing/pricing.tsx +++ b/apps/sim/app/(landing)/components/pricing/pricing.tsx @@ -38,7 +38,7 @@ const PRICING_TIERS: PricingTier[] = [ features: [ '1,000 credits (trial)', '5GB file storage', - '3 tables · 1,000 rows each', + '5 tables · 50,000 rows each', '1 personal workspace', '5 min execution limit', '7-day log retention', @@ -56,7 +56,7 @@ const PRICING_TIERS: PricingTier[] = [ features: [ '6,000 credits/mo · +50/day', '50GB file storage', - '25 tables · 5,000 rows each', + '100 tables · 100,000 rows each', 'Up to 3 personal workspaces', '50 min execution · 150 runs/min', 'Unlimited log retention', @@ -74,7 +74,7 @@ const PRICING_TIERS: PricingTier[] = [ features: [ '25,000 credits/mo · +200/day', '500GB file storage', - '25 tables · 5,000 rows each', + '1,000 tables · 500,000 rows each', 'Up to 10 personal workspaces', '50 min execution · 300 runs/min', 'Unlimited log retention', @@ -91,7 +91,7 @@ const PRICING_TIERS: PricingTier[] = [ features: [ 'Custom credits & infra limits', 'Custom file storage', - '10,000 tables · 1M rows each', + 'Custom tables & rows', 'Unlimited shared workspaces', 'Custom execution limits', 'Unlimited log retention', diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 0baeb6d70a1..0c1b9fe923d 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -55,6 +55,17 @@ transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1); } +/** + * Collapsed width is driven by the server-rendered `data-collapsed` attribute — + * the same cookie source as the collapsed structure — so the rail can never paint + * at the expanded width and then snap narrow. Overrides `--sidebar-width` for the + * shell subtree (outer, inner, and the aside cascade from it). Must equal + * SIDEBAR_WIDTH.COLLAPSED in stores/constants.ts. + */ +.sidebar-shell-outer[data-collapsed] { + --sidebar-width: 51px; +} + .sidebar-container span, .sidebar-container .text-small { transition: opacity 120ms ease; @@ -66,38 +77,11 @@ opacity: 0; } -html[data-sidebar-collapsed] .sidebar-container span, -html[data-sidebar-collapsed] .sidebar-container .text-small { - opacity: 0; -} - .sidebar-container .sidebar-collapse-hide { transition: opacity 60ms ease; } -.sidebar-container .sidebar-collapse-show { - opacity: 0; - pointer-events: none; - transition: opacity 120ms ease-out; -} - -.sidebar-container[data-collapsed] .sidebar-collapse-hide, -html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide { - opacity: 0; -} - -.sidebar-container[data-collapsed] .sidebar-collapse-show, -html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-show { - opacity: 1; - pointer-events: auto; -} - -html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove { - display: none; -} - -html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn { - width: 0; +.sidebar-container[data-collapsed] .sidebar-collapse-hide { opacity: 0; } diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index cb81a810b1b..a3ff4c12097 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -1,14 +1,15 @@ import { db } from '@sim/db' import { account, credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' -import { and, eq } from 'drizzle-orm' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import { and, eq, isNotNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { oauthCredentialsQuerySchema } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { getCanonicalScopesForProvider, @@ -114,11 +115,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined } + let requesterCanAdmin = false if (effectiveWorkspaceId) { const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId) if (!workspaceAccess.hasAccess) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + requesterCanAdmin = workspaceAccess.canAdmin } if (credentialId) { @@ -150,19 +153,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } if (!workflowId) { - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, platformCredential.id), - eq(credentialMember.userId, requesterUserId), - eq(credentialMember.status, 'active') - ) - ) - .limit(1) - - if (!membership) { + const access = await getCredentialActorContext(platformCredential.id, requesterUserId) + if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } } @@ -193,19 +185,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } } else { - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, platformCredential.id), - eq(credentialMember.userId, requesterUserId), - eq(credentialMember.status, 'active') - ) - ) - .limit(1) - - if (!membership) { + const access = await getCredentialActorContext(platformCredential.id, requesterUserId) + if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } } @@ -237,17 +218,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { userId: requesterUserId, }) + const oauthSelect = { + id: credential.id, + displayName: credential.displayName, + providerId: account.providerId, + scope: account.scope, + updatedAt: account.updatedAt, + } const credentialsData = await db - .select({ - id: credential.id, - displayName: credential.displayName, - providerId: account.providerId, - scope: account.scope, - updatedAt: account.updatedAt, - }) + .select(oauthSelect) .from(credential) .innerJoin(account, eq(credential.accountId, account.id)) - .innerJoin( + .leftJoin( credentialMember, and( eq(credentialMember.credentialId, credential.id), @@ -259,7 +241,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { and( eq(credential.workspaceId, effectiveWorkspaceId), eq(credential.type, 'oauth'), - eq(account.providerId, providerParam) + eq(account.providerId, providerParam), + requesterCanAdmin ? undefined : isNotNull(credentialMember.id) ) ) @@ -270,15 +253,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const saProviderId = getServiceAccountProviderForProviderId(providerParam) if (saProviderId) { + const saSelect = { + id: credential.id, + displayName: credential.displayName, + providerId: credential.providerId, + updatedAt: credential.updatedAt, + } const serviceAccountCreds = await db - .select({ - id: credential.id, - displayName: credential.displayName, - providerId: credential.providerId, - updatedAt: credential.updatedAt, - }) + .select(saSelect) .from(credential) - .innerJoin( + .leftJoin( credentialMember, and( eq(credentialMember.credentialId, credential.id), @@ -290,7 +274,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { and( eq(credential.workspaceId, effectiveWorkspaceId), eq(credential.type, 'service_account'), - eq(credential.providerId, saProviderId) + eq(credential.providerId, saProviderId), + requesterCanAdmin ? undefined : isNotNull(credentialMember.id) ) ) diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index dc02c328436..2425df34db3 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -366,7 +366,7 @@ export const GET = withRouteHandler( deployment.authType !== 'public' && deployment.authType !== 'sso' && authCookie && - validateAuthToken(authCookie.value, deployment.id, deployment.password) + validateAuthToken(authCookie.value, deployment.id, deployment.authType, deployment.password) ) { return createSuccessResponse(toChatConfigResponse(deployment)) } diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 0f0a5b6406a..a6eea9107e4 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -76,6 +76,7 @@ vi.mock('@/lib/core/security/deployment', () => ({ validateAuthToken: mockValidateAuthToken, setDeploymentAuthCookie: mockSetDeploymentAuthCookie, isEmailAllowed: mockIsEmailAllowed, + deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`, })) vi.mock('@/lib/core/config/env-flags', () => ({ @@ -134,6 +135,7 @@ describe('Chat API Utils', () => { expect(mockValidateAuthToken).toHaveBeenCalledWith( 'valid-token', 'chat-id', + 'password', 'encrypted-password' ) expect(result.authorized).toBe(true) @@ -407,7 +409,7 @@ describe('Chat API Utils', () => { }) expect(result.authorized).toBe(false) - expect(result.error).toBe('Your email is not authorized to access this chat') + expect(result.error).toBe('Your email is not authorized to access this resource') }) }) }) diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 49c5f170645..c200a47adb5 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -1,38 +1,21 @@ import { db } from '@sim/db' import { chat, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { safeCompare } from '@sim/security/compare' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access' import { getEnv } from '@/lib/core/config/env' import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags' -import type { TokenBucketConfig } from '@/lib/core/rate-limiter' -import { RateLimiter } from '@/lib/core/rate-limiter' +import { setDeploymentAuthCookie } from '@/lib/core/security/deployment' import { - isEmailAllowed, - setDeploymentAuthCookie, - validateAuthToken, -} from '@/lib/core/security/deployment' -import { decryptSecret } from '@/lib/core/security/encryption' -import { getClientIp } from '@/lib/core/utils/request' + type DeploymentAuthResult, + validateDeploymentAuth, +} from '@/lib/core/security/deployment-auth' import { createErrorResponse } from '@/app/api/workflows/utils' const logger = createLogger('ChatAuthUtils') -const rateLimiter = new RateLimiter() - -/** - * Throttles unauthenticated password guesses per client IP against a single - * deployment, mirroring the OTP/SSO IP limits. - */ -const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = { - maxTokens: 10, - refillRate: 10, - refillIntervalMs: 15 * 60_000, -} - export function setChatAuthCookie( response: NextResponse, chatId: string, @@ -157,144 +140,15 @@ export async function checkChatAccess( : { hasAccess: false } } +/** + * Validates auth for a deployed chat. Thin wrapper over the shared + * {@link validateDeploymentAuth} with the `'chat'` cookie/rate-limit namespace. + */ export async function validateChatAuth( requestId: string, deployment: any, request: NextRequest, parsedBody?: any -): Promise<{ authorized: boolean; error?: string; status?: number; retryAfterMs?: number }> { - const authType = deployment.authType || 'public' - - if (authType === 'public') { - return { authorized: true } - } - - if (authType !== 'sso') { - const cookieName = `chat_auth_${deployment.id}` - const authCookie = request.cookies.get(cookieName) - - if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) { - return { authorized: true } - } - } - - if (authType === 'password') { - if (request.method === 'GET') { - return { authorized: false, error: 'auth_required_password' } - } - - try { - if (!parsedBody) { - return { authorized: false, error: 'Password is required' } - } - - const { password, input } = parsedBody - - if (input && !password) { - return { authorized: false, error: 'auth_required_password' } - } - - if (!password) { - return { authorized: false, error: 'Password is required' } - } - - if (!deployment.password) { - logger.error(`[${requestId}] No password set for password-protected chat: ${deployment.id}`) - return { authorized: false, error: 'Authentication configuration error' } - } - - const ip = getClientIp(request) - const ipRateLimit = await rateLimiter.checkRateLimitDirect( - `chat-password:ip:${deployment.id}:${ip}`, - PASSWORD_IP_RATE_LIMIT - ) - if (!ipRateLimit.allowed) { - logger.warn( - `[${requestId}] Password attempt IP rate limit exceeded for chat ${deployment.id} from ${ip}` - ) - return { - authorized: false, - error: 'Too many attempts. Please try again later.', - status: 429, - retryAfterMs: ipRateLimit.retryAfterMs ?? PASSWORD_IP_RATE_LIMIT.refillIntervalMs, - } - } - - const { decrypted } = await decryptSecret(deployment.password) - if (!safeCompare(password, decrypted)) { - return { authorized: false, error: 'Invalid password' } - } - - return { authorized: true } - } catch (error) { - logger.error(`[${requestId}] Error validating password:`, error) - return { authorized: false, error: 'Authentication error' } - } - } - - if (authType === 'email') { - if (request.method === 'GET') { - return { authorized: false, error: 'auth_required_email' } - } - - try { - if (!parsedBody) { - return { authorized: false, error: 'Email is required' } - } - - const { email, input } = parsedBody - - if (input && !email) { - return { authorized: false, error: 'auth_required_email' } - } - - if (!email) { - return { authorized: false, error: 'Email is required' } - } - - const allowedEmails = deployment.allowedEmails || [] - - if (isEmailAllowed(email, allowedEmails)) { - return { authorized: false, error: 'otp_required' } - } - - return { authorized: false, error: 'Email not authorized' } - } catch (error) { - logger.error(`[${requestId}] Error validating email:`, error) - return { authorized: false, error: 'Authentication error' } - } - } - - if (authType === 'sso') { - try { - if (request.method !== 'GET' && !parsedBody) { - return { authorized: false, error: 'SSO authentication is required' } - } - - const { getSession } = await import('@/lib/auth') - const session = await getSession() - - if (!session || !session.user) { - return { authorized: false, error: 'auth_required_sso' } - } - - const userEmail = session.user.email - if (!userEmail) { - return { authorized: false, error: 'SSO session does not contain email' } - } - - const allowedEmails = deployment.allowedEmails || [] - - if (isEmailAllowed(userEmail, allowedEmails)) { - return { authorized: true } - } - - return { authorized: false, error: 'Your email is not authorized to access this chat' } - } catch (error) { - logger.error(`[${requestId}] Error validating SSO:`, error) - return { authorized: false, error: 'SSO authentication error' } - } - } - - return { authorized: false, error: 'Unsupported authentication type' } +): Promise { + return validateDeploymentAuth(requestId, deployment, request, parsedBody, 'chat') } diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts index 55d8f5acad0..c7fa4d58b3f 100644 --- a/apps/sim/app/api/copilot/chat/queries.ts +++ b/apps/sim/app/api/copilot/chat/queries.ts @@ -1,8 +1,8 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' diff --git a/apps/sim/app/api/copilot/chats/route.test.ts b/apps/sim/app/api/copilot/chats/route.test.ts index 11046b7a349..2ce59e2a41d 100644 --- a/apps/sim/app/api/copilot/chats/route.test.ts +++ b/apps/sim/app/api/copilot/chats/route.test.ts @@ -24,6 +24,7 @@ vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), or: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'or' })), + inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })), isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), sql: vi.fn(), @@ -31,6 +32,14 @@ vi.mock('drizzle-orm', () => ({ vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/workspaces/utils', () => ({ + listAccessibleWorkspaceRowsForUser: vi + .fn() + .mockResolvedValue([ + { workspace: { id: 'workspace-123', createdAt: new Date() }, permissionType: 'admin' }, + ]), +})) + import { GET } from '@/app/api/copilot/chats/route' describe('Copilot Chats List API Route', () => { diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index c72bfa1c8ba..5f72001816b 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -1,8 +1,8 @@ import { db } from '@sim/db' -import { copilotChats, permissions, workflow, workspace } from '@sim/db/schema' +import { copilotChats, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' -import { and, desc, eq, isNull, or, sql } from 'drizzle-orm' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkflowCopilotChatContract } from '@/lib/api/contracts/copilot' import { parseRequest, validationErrorResponse } from '@/lib/api/server' @@ -20,6 +20,7 @@ import { assertActiveWorkspaceAccess, isWorkspaceAccessDeniedError, } from '@/lib/workspaces/permissions/utils' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' const logger = createLogger('CopilotChatsListAPI') @@ -32,6 +33,21 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { return createUnauthorizedResponse() } + // Active accessible workspaces (explicit + org-derived). Using the active + // scope keeps the archived-workspace exclusion the old join-based query had. + const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId) + const accessibleWorkspaceIds = accessibleRows.map((row) => row.workspace.id) + const inAccessibleWorkspace = + accessibleWorkspaceIds.length > 0 + ? or( + inArray(workflow.workspaceId, accessibleWorkspaceIds), + and( + isNull(copilotChats.workflowId), + inArray(copilotChats.workspaceId, accessibleWorkspaceIds) + ) + ) + : undefined + const visibleChats = await db .selectDistinctOn([copilotChats.id], { id: copilotChats.id, @@ -43,30 +59,14 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { }) .from(copilotChats) .leftJoin(workflow, eq(copilotChats.workflowId, workflow.id)) - .leftJoin( - workspace, - or( - eq(workflow.workspaceId, workspace.id), - and(isNull(copilotChats.workflowId), eq(copilotChats.workspaceId, workspace.id)) - ) - ) - .leftJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspace.id), - eq(permissions.userId, userId) - ) - ) .where( and( eq(copilotChats.userId, userId), or( and(isNull(copilotChats.workflowId), isNull(copilotChats.workspaceId)), - sql`${permissions.id} IS NOT NULL` + inAccessibleWorkspace ), - or(isNull(workflow.id), isNull(workflow.archivedAt)), - or(isNull(workspace.id), isNull(workspace.archivedAt)) + or(isNull(workflow.id), isNull(workflow.archivedAt)) ) ) .orderBy(copilotChats.id, desc(copilotChats.updatedAt)) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index 5ccda51212d..f784dc48d84 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowCheckpoints, workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { revertCopilotCheckpointContract } from '@/lib/api/contracts/copilot' diff --git a/apps/sim/app/api/copilot/checkpoints/route.ts b/apps/sim/app/api/copilot/checkpoints/route.ts index 985a95a71a3..4bd861ffb50 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowCheckpoints } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 7753d2fc21c..72132ee56d0 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -5,12 +5,19 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { upsertWorkspaceCredentialMemberContract } from '@/lib/api/contracts/credentials' +import { + upsertWorkspaceCredentialMemberContract, + type WorkspaceCredentialMember, +} from '@/lib/api/contracts/credentials' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deriveCredentialAdmin, isSharedCredentialType } from '@/lib/credentials/access' import { captureServerEvent } from '@/lib/posthog/server' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + getUserEntityPermissions, + getUsersWithPermissions, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('CredentialMembersAPI') @@ -18,7 +25,7 @@ interface RouteContext { params: Promise<{ id: string }> } -async function requireWorkspaceAdminMembership(credentialId: string, userId: string) { +async function requireCredentialAdmin(credentialId: string, userId: string) { const [cred] = await db .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) .from(credential) @@ -38,10 +45,16 @@ async function requireWorkspaceAdminMembership(credentialId: string, userId: str ) .limit(1) - if (!membership || membership.status !== 'active' || membership.role !== 'admin') { + const isAdmin = deriveCredentialAdmin({ + credentialType: cred.type, + memberRole: membership?.status === 'active' ? membership.role : null, + workspaceCanAdmin: perm === 'admin', + }) + + if (!isAdmin) { return null } - return { ...membership, credentialType: cred.type, workspaceId: cred.workspaceId } + return { credentialType: cred.type, workspaceId: cred.workspaceId } } export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => { @@ -54,7 +67,7 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route const { id: credentialId } = await context.params const [cred] = await db - .select({ id: credential.id, workspaceId: credential.workspaceId }) + .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) .from(credential) .where(eq(credential.id, credentialId)) .limit(1) @@ -72,7 +85,7 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route return NextResponse.json({ error: 'Not found' }, { status: 404 }) } - const members = await db + const explicitMembers = await db .select({ id: credentialMember.id, userId: credentialMember.userId, @@ -86,6 +99,48 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route .innerJoin(user, eq(credentialMember.userId, user.id)) .where(eq(credentialMember.credentialId, credentialId)) + const byUser = new Map( + explicitMembers.map((m) => [ + m.userId, + { + id: m.id, + userId: m.userId, + role: m.role, + status: m.status, + joinedAt: m.joinedAt ? m.joinedAt.toISOString() : null, + userName: m.userName, + userEmail: m.userEmail, + roleSource: 'explicit' as const, + }, + ]) + ) + + if (isSharedCredentialType(cred.type)) { + const workspaceMembers = await getUsersWithPermissions(cred.workspaceId) + for (const wsMember of workspaceMembers) { + if (wsMember.permissionType !== 'admin') continue + const existing = byUser.get(wsMember.userId) + if (existing) { + existing.role = 'admin' + existing.status = 'active' + existing.roleSource = 'workspace-admin' + } else { + byUser.set(wsMember.userId, { + id: `workspace-admin-${wsMember.userId}`, + userId: wsMember.userId, + role: 'admin', + status: 'active', + joinedAt: null, + userName: wsMember.name, + userEmail: wsMember.email, + roleSource: 'workspace-admin', + }) + } + } + } + + const members = Array.from(byUser.values()) + return NextResponse.json({ members }) } catch (error) { logger.error('Failed to fetch credential members', { error }) @@ -102,7 +157,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route const { id: credentialId } = await context.params - const admin = await requireWorkspaceAdminMembership(credentialId, session.user.id) + const admin = await requireCredentialAdmin(credentialId, session.user.id) if (!admin) { logger.warn('Credential member share denied', { credentialId, @@ -111,7 +166,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route }) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } - if (admin.credentialType === 'env_personal') { + if (!isSharedCredentialType(admin.credentialType)) { logger.warn('Credential member share denied', { credentialId, actorId: session.user.id, @@ -124,6 +179,19 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route if (!parsed.success) return parsed.response const { userId, role } = parsed.data.body + + const targetWorkspacePerm = await getUserEntityPermissions( + userId, + 'workspace', + admin.workspaceId + ) + if (targetWorkspacePerm === 'admin' && role !== 'admin') { + return NextResponse.json( + { error: 'Workspace admins are automatically credential admins and cannot be demoted' }, + { status: 400 } + ) + } + const now = new Date() const [existing] = await db @@ -142,7 +210,12 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route .where(eq(credentialMember.id, existing.id)) .limit(1) .for('update') - if (current?.role === 'admin' && current?.status === 'active' && role !== 'admin') { + if ( + !isSharedCredentialType(admin.credentialType) && + current?.role === 'admin' && + current?.status === 'active' && + role !== 'admin' + ) { const activeAdmins = await tx .select({ id: credentialMember.id }) .from(credentialMember) @@ -233,7 +306,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 }) } - const admin = await requireWorkspaceAdminMembership(credentialId, session.user.id) + const admin = await requireCredentialAdmin(credentialId, session.user.id) if (!admin) { logger.warn('Credential member removal denied', { credentialId, @@ -262,8 +335,22 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou return NextResponse.json({ error: 'Member not found' }, { status: 404 }) } + if (isSharedCredentialType(admin.credentialType)) { + const targetWorkspacePerm = await getUserEntityPermissions( + targetUserId, + 'workspace', + admin.workspaceId + ) + if (targetWorkspacePerm === 'admin') { + return NextResponse.json( + { error: 'Workspace admins are automatically credential admins and cannot be removed' }, + { status: 400 } + ) + } + } + const revoked = await db.transaction(async (tx) => { - if (target.role === 'admin') { + if (!isSharedCredentialType(admin.credentialType) && target.role === 'admin') { const activeAdmins = await tx .select({ id: credentialMember.id }) .from(credentialMember) diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index f846a88d3fb..3dc507ed5ae 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -1,44 +1,34 @@ -import { db } from '@sim/db' -import { credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access' import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' const logger = createLogger('CredentialByIdAPI') -async function getCredentialResponse(credentialId: string, userId: string) { - const [row] = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - displayName: credential.displayName, - description: credential.description, - providerId: credential.providerId, - accountId: credential.accountId, - envKey: credential.envKey, - envOwnerUserId: credential.envOwnerUserId, - createdBy: credential.createdBy, - createdAt: credential.createdAt, - updatedAt: credential.updatedAt, - role: credentialMember.role, - status: credentialMember.status, - }) - .from(credential) - .innerJoin( - credentialMember, - and(eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, userId)) - ) - .where(eq(credential.id, credentialId)) - .limit(1) - - return row ?? null +function formatCredentialResponse(access: CredentialActorContext) { + const cred = access.credential + if (!cred) return null + + return { + id: cred.id, + workspaceId: cred.workspaceId, + type: cred.type, + displayName: cred.displayName, + description: cred.description, + providerId: cred.providerId, + accountId: cred.accountId, + envKey: cred.envKey, + envOwnerUserId: cred.envOwnerUserId, + createdBy: cred.createdBy, + createdAt: cred.createdAt, + updatedAt: cred.updatedAt, + role: access.isAdmin ? 'admin' : (access.member?.role ?? null), + status: access.member?.status ?? (access.isAdmin ? 'active' : null), + } } export const GET = withRouteHandler( @@ -55,12 +45,11 @@ export const GET = withRouteHandler( if (!access.credential) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } - if (!access.hasWorkspaceAccess || !access.member) { + if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const row = await getCredentialResponse(id, session.user.id) - return NextResponse.json({ credential: row }, { status: 200 }) + return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 }) } catch (error) { logger.error('Failed to fetch credential', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) @@ -109,8 +98,8 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: result.error }, { status }) } - const row = await getCredentialResponse(id, session.user.id) - return NextResponse.json({ credential: row }, { status: 200 }) + const access = await getCredentialActorContext(id, session.user.id) + return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 }) } catch (error) { logger.error('Failed to update credential', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index 9efb27f2619..2e693609438 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { credential, credentialMember, pendingCredentialDraft } from '@sim/db/schema' +import { pendingCredentialDraft } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, lt } from 'drizzle-orm' @@ -8,6 +8,7 @@ import { createCredentialDraftContract } from '@/lib/api/contracts/credentials' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CredentialDraftAPI') @@ -33,22 +34,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } if (credentialId) { - const [membership] = await db - .select({ role: credentialMember.role, status: credentialMember.status }) - .from(credentialMember) - .innerJoin(credential, eq(credential.id, credentialMember.credentialId)) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.userId, userId), - eq(credentialMember.status, 'active'), - eq(credentialMember.role, 'admin'), - eq(credential.workspaceId, workspaceId) - ) - ) - .limit(1) - - if (!membership) { + const access = await getCredentialActorContext(credentialId, userId, { workspaceAccess }) + if (!access.credential || access.credential.workspaceId !== workspaceId || !access.isAdmin) { return NextResponse.json( { error: 'Admin access required on the target credential' }, { status: 403 } diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 3b964b18e0b..318b905987b 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -4,7 +4,7 @@ import { account, credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray, isNotNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceCredentialContract, @@ -17,6 +17,11 @@ import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + getCredentialActorContext, + isSharedCredentialType, + SHARED_CREDENTIAL_TYPES, +} from '@/lib/credentials/access' import { AtlassianValidationError, normalizeAtlassianDomain, @@ -228,7 +233,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { whereClauses.push(eq(credential.providerId, providerId)) } - const credentials = await db + const isWorkspaceAdmin = workspaceAccess.canAdmin + const accessClause = isWorkspaceAdmin + ? or( + isNotNull(credentialMember.id), + inArray(credential.type, SHARED_CREDENTIAL_TYPES), + eq(credential.envOwnerUserId, session.user.id) + ) + : or(isNotNull(credentialMember.id), eq(credential.envOwnerUserId, session.user.id)) + + const rows = await db .select({ id: credential.id, workspaceId: credential.workspaceId, @@ -242,10 +256,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { createdBy: credential.createdBy, createdAt: credential.createdAt, updatedAt: credential.updatedAt, - role: credentialMember.role, + memberRole: credentialMember.role, }) .from(credential) - .innerJoin( + .leftJoin( credentialMember, and( eq(credentialMember.credentialId, credential.id), @@ -253,7 +267,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { eq(credentialMember.status, 'active') ) ) - .where(and(...whereClauses)) + .where(and(...whereClauses, accessClause)) + + const credentials = rows.map(({ memberRole, ...rest }) => ({ + ...rest, + role: + isWorkspaceAdmin && isSharedCredentialType(rest.type) ? 'admin' : (memberRole ?? 'member'), + })) return NextResponse.json({ credentials }) } catch (error) { @@ -440,29 +460,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (existingCredential) { - const [membership] = await db - .select({ - id: credentialMember.id, - status: credentialMember.status, - role: credentialMember.role, - }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, existingCredential.id), - eq(credentialMember.userId, session.user.id) - ) - ) - .limit(1) + const access = await getCredentialActorContext(existingCredential.id, session.user.id, { + workspaceAccess, + }) - if (!membership || membership.status !== 'active') { + if (!access.member && !access.isAdmin) { return NextResponse.json( { error: 'A credential with this source already exists in this workspace' }, { status: 409 } ) } - const canUpdateExistingCredential = membership.role === 'admin' + const canUpdateExistingCredential = access.isAdmin const shouldUpdateDisplayName = type === 'oauth' && resolvedDisplayName && @@ -498,11 +507,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const now = new Date() const credentialId = generateId() - const { - ownerId: workspaceOwnerId, - memberUserIds: workspaceMemberUserIds, - adminUserIds: workspaceAdminUserIds, - } = await getWorkspaceMembership(workspaceId) + const { ownerId: workspaceOwnerId, memberUserIds: workspaceMemberUserIds } = + await getWorkspaceMembership(workspaceId) await db.transaction(async (tx) => { // service_account has no DB-level unique index on (workspaceId, providerId, @@ -537,8 +543,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if ((type === 'env_workspace' || type === 'service_account') && workspaceOwnerId) { if (workspaceMemberUserIds.length > 0) { for (const memberUserId of workspaceMemberUserIds) { - const isAdmin = - memberUserId === session.user.id || workspaceAdminUserIds.has(memberUserId) + const isAdmin = memberUserId === session.user.id await tx.insert(credentialMember).values({ id: generateId(), credentialId, diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 7b96031baf8..8e0c66b4173 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { document, knowledgeBase, workspaceFile } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { permissionSatisfies } from '@sim/platform-authz/workspace' import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getFileMetadata } from '@/lib/uploads' @@ -39,8 +40,7 @@ function workspacePermissionSatisfies( permission: WorkspacePermission | null, requireWrite: boolean ): boolean { - if (permission === null) return false - return requireWrite ? permission === 'write' || permission === 'admin' : true + return permissionSatisfies(permission, requireWrite ? 'write' : 'read') } /** diff --git a/apps/sim/app/api/files/multipart/route.test.ts b/apps/sim/app/api/files/multipart/route.test.ts index 520a05dd065..798f86303ef 100644 --- a/apps/sim/app/api/files/multipart/route.test.ts +++ b/apps/sim/app/api/files/multipart/route.test.ts @@ -38,7 +38,7 @@ vi.mock('@/lib/uploads/core/upload-token', () => ({ vi.mock('@/lib/uploads/providers/s3/client', () => ({ completeS3MultipartUpload: mockCompleteS3MultipartUpload, - initiateS3MultipartUpload: vi.fn(), + initiateS3MultipartUpload: mockInitiateS3MultipartUpload, getS3MultipartPartUrls: vi.fn(), abortS3MultipartUpload: vi.fn(), })) @@ -247,31 +247,38 @@ describe('POST /api/files/multipart action=initiate quota enforcement', () => { expect(body.error).toContain('Storage limit exceeded') }) - it('does not check quota for quota-exempt contexts (og-images)', async () => { + it('allows quota-enforced contexts that pass the quota check', async () => { const res = await makeInitiateRequest({ - fileName: 'img.png', - contentType: 'image/png', + fileName: 'doc.pdf', + contentType: 'application/pdf', fileSize: 99999, workspaceId: 'ws-1', - context: 'og-images', + context: 'knowledge-base', }) const response = await POST(res) - expect(mockCheckStorageQuota).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 99999) + expect(mockInitiateS3MultipartUpload).toHaveBeenCalled() }) - it('rejects logs context — not allowed via the multipart endpoint', async () => { - const res = await makeInitiateRequest({ - fileName: 'exec.log', - contentType: 'text/plain', - fileSize: 1000, - workspaceId: 'ws-1', - context: 'logs', - }) + it.each(['og-images', 'profile-pictures', 'workspace-logos', 'logs'])( + 'rejects quota-exempt context %s — not allowed via the multipart endpoint', + async (context) => { + const res = await makeInitiateRequest({ + fileName: 'asset.png', + contentType: 'image/png', + fileSize: 100 * 1024 * 1024 * 1024, + workspaceId: 'ws-1', + context, + }) - const response = await POST(res) - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toMatch(/invalid storage context/i) - }) + const response = await POST(res) + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toMatch(/invalid storage context/i) + expect(mockCheckStorageQuota).not.toHaveBeenCalled() + expect(mockInitiateS3MultipartUpload).not.toHaveBeenCalled() + } + ) }) diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts index fbdb4e4016a..1e570c3b9a9 100644 --- a/apps/sim/app/api/files/multipart/route.ts +++ b/apps/sim/app/api/files/multipart/route.ts @@ -30,6 +30,15 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MultipartUploadAPI') +/** + * Contexts the multipart endpoint accepts. The quota-exempt public-asset + * contexts (`profile-pictures`, `workspace-logos`, `og-images`) and the + * system-internal `logs` context are deliberately excluded: their uploads are + * small images capped far below the multipart threshold and routed through the + * presigned endpoint, so they have no large-file flow here. Accepting them would + * only expose a path that bypasses the per-user storage quota, since every + * context in this set is quota-enforced below. + */ const ALLOWED_UPLOAD_CONTEXTS = new Set([ 'knowledge-base', 'chat', @@ -37,9 +46,6 @@ const ALLOWED_UPLOAD_CONTEXTS = new Set([ 'mothership', 'execution', 'workspace', - 'profile-pictures', - 'og-images', - 'workspace-logos', ]) /** @@ -159,7 +165,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const config = getStorageConfig(storageContext) - if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(context as StorageContext)) { + if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(storageContext)) { const { checkStorageQuota } = await import('@/lib/billing/storage') const quotaCheck = await checkStorageQuota(userId, fileSize ?? 0) if (!quotaCheck.allowed) { diff --git a/apps/sim/app/api/files/public/[token]/content/route.test.ts b/apps/sim/app/api/files/public/[token]/content/route.test.ts new file mode 100644 index 00000000000..251f96f83e3 --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/content/route.test.ts @@ -0,0 +1,90 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockResolveActiveShareByToken, + mockEnforceRateLimit, + mockValidateDeploymentAuth, + mockDownloadFile, + mockResolveServableDoc, +} = vi.hoisted(() => ({ + mockResolveActiveShareByToken: vi.fn(), + mockEnforceRateLimit: vi.fn(), + mockValidateDeploymentAuth: vi.fn(), + mockDownloadFile: vi.fn(), + mockResolveServableDoc: vi.fn(), +})) + +vi.mock('@/lib/public-shares/share-manager', () => ({ + resolveActiveShareByToken: mockResolveActiveShareByToken, +})) + +vi.mock('@/lib/public-shares/rate-limit', () => ({ + enforcePublicFileRateLimit: mockEnforceRateLimit, +})) + +vi.mock('@/lib/core/security/deployment-auth', () => ({ + validateDeploymentAuth: mockValidateDeploymentAuth, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFile: mockDownloadFile, +})) + +vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ + resolveServableDoc: mockResolveServableDoc, +})) + +import { GET } from '@/app/api/files/public/[token]/content/route' + +const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) }) +const request = (token = 'tok_1') => + new NextRequest(`http://localhost/api/files/public/${token}/content`) + +const passwordShare = { + share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' }, + file: { + id: 'wf_1', + key: 'workspace/ws/secret-key.pdf', + workspaceId: 'ws-1', + originalName: 'report.pdf', + contentType: 'application/pdf', + size: 4, + }, + workspaceName: 'Acme', + ownerName: 'Jane', +} + +describe('GET /api/files/public/[token]/content', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnforceRateLimit.mockResolvedValue(null) + mockResolveActiveShareByToken.mockResolvedValue(passwordShare) + mockDownloadFile.mockResolvedValue(Buffer.from('data')) + mockResolveServableDoc.mockResolvedValue({ kind: 'passthrough' }) + }) + + it('returns 401 and never reads storage when a password share is unauthorized', async () => { + mockValidateDeploymentAuth.mockResolvedValueOnce({ + authorized: false, + error: 'auth_required_password', + }) + const res = await GET(request(), params()) + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('auth_required_password') + expect(mockDownloadFile).not.toHaveBeenCalled() + }) + + it('serves the bytes once authorized', async () => { + mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true }) + const res = await GET(request(), params()) + expect(res.status).toBe(200) + expect(mockDownloadFile).toHaveBeenCalledWith({ + key: passwordShare.file.key, + context: 'workspace', + }) + }) +}) diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts new file mode 100644 index 00000000000..8db42e412bd --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -0,0 +1,95 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares' +import { parseRequest } from '@/lib/api/server' +import { resolveServableDoc } from '@/lib/copilot/tools/server/files/doc-compile' +import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' +import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { createErrorResponse, createFileResponse, FileNotFoundError } from '@/app/api/files/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('PublicFileContentAPI') + +/** + * GET /api/files/public/[token]/content + * Public, unauthenticated bytes for a shared file. Authorized solely by an active + * share token — never by workspace membership. 404 for unknown/inactive/deleted + * shares. Disposition (inline vs attachment) is resolved from the file type by + * {@link createFileResponse}; the public page's Download button uses ``. + * + * Generated office docs are stored as source; {@link resolveServableDoc} swaps in + * their prebuilt compiled binary (read-only, never compiles). Uploaded binaries + * pass through untouched. A generated doc whose compiled artifact isn't built yet + * returns 409 rather than serving raw source under a binary content type. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const requestId = generateRequestId() + + try { + const limited = await enforcePublicFileRateLimit(request, 'content') + if (limited) return limited + + const parsed = await parseRequest(getPublicFileContentContract, request, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params + + const resolved = await resolveActiveShareByToken(token) + if (!resolved) { + throw new FileNotFoundError('Not found') + } + + const auth = await validateDeploymentAuth( + requestId, + resolved.share, + request, + undefined, + 'file' + ) + if (!auth.authorized) { + return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) + } + + const { file } = resolved + const raw = await downloadFile({ key: file.key, context: 'workspace' }) + + const servable = file.workspaceId + ? await resolveServableDoc(file.workspaceId, raw, file.originalName) + : ({ kind: 'passthrough' } as const) + + if (servable.kind === 'unavailable') { + logger.info('Public shared doc not yet compiled', { token, key: file.key }) + return NextResponse.json( + { error: 'This document is still being prepared. Please try again shortly.' }, + { status: 409 } + ) + } + + const buffer = servable.kind === 'artifact' ? servable.buffer : raw + const contentType = servable.kind === 'artifact' ? servable.contentType : file.contentType + + logger.info('Public shared file served', { token, key: file.key, size: buffer.length }) + + // Revalidate every request: a shared file can be unshared, edited, or deleted, + // so the fixed token URL must never serve stale bytes from a long-lived cache. + return createFileResponse({ + buffer, + contentType, + filename: file.originalName, + cacheControl: 'private, no-cache, must-revalidate', + }) + } catch (error) { + logger.error('Error serving public shared file:', error) + if (error instanceof FileNotFoundError) { + return createErrorResponse(error) + } + return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file')) + } + } +) diff --git a/apps/sim/app/api/files/public/[token]/otp/route.test.ts b/apps/sim/app/api/files/public/[token]/otp/route.test.ts new file mode 100644 index 00000000000..eb363eb7d5f --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/otp/route.test.ts @@ -0,0 +1,171 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockResolveActiveShareByToken, + mockIsEmailAllowed, + mockSetDeploymentAuthCookie, + mockGenerateOTP, + mockStoreOTP, + mockGetOTP, + mockDeleteOTP, + mockIncrementOTPAttempts, + mockDecodeOTPValue, + mockRenderOTPEmail, + mockSendEmail, + mockCheckRateLimitDirect, +} = vi.hoisted(() => ({ + mockResolveActiveShareByToken: vi.fn(), + mockIsEmailAllowed: vi.fn(), + mockSetDeploymentAuthCookie: vi.fn(), + mockGenerateOTP: vi.fn(), + mockStoreOTP: vi.fn(), + mockGetOTP: vi.fn(), + mockDeleteOTP: vi.fn(), + mockIncrementOTPAttempts: vi.fn(), + mockDecodeOTPValue: vi.fn(), + mockRenderOTPEmail: vi.fn(), + mockSendEmail: vi.fn(), + mockCheckRateLimitDirect: vi.fn(), +})) + +vi.mock('@/lib/public-shares/share-manager', () => ({ + resolveActiveShareByToken: mockResolveActiveShareByToken, +})) +vi.mock('@/lib/core/security/deployment', () => ({ + isEmailAllowed: mockIsEmailAllowed, + setDeploymentAuthCookie: mockSetDeploymentAuthCookie, +})) +vi.mock('@/lib/core/security/otp', () => ({ + generateOTP: mockGenerateOTP, + storeOTP: mockStoreOTP, + getOTP: mockGetOTP, + deleteOTP: mockDeleteOTP, + incrementOTPAttempts: mockIncrementOTPAttempts, + decodeOTPValue: mockDecodeOTPValue, + MAX_OTP_ATTEMPTS: 5, + OTP_IP_RATE_LIMIT: { maxTokens: 10, refillRate: 10, refillIntervalMs: 1000 }, + OTP_EMAIL_RATE_LIMIT: { maxTokens: 3, refillRate: 3, refillIntervalMs: 1000 }, +})) +vi.mock('@/components/emails', () => ({ renderOTPEmail: mockRenderOTPEmail })) +vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mockSendEmail })) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mockCheckRateLimitDirect + }, +})) + +import { POST, PUT } from '@/app/api/files/public/[token]/otp/route' + +const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) }) +const post = (email: string, token = 'tok_1') => + new NextRequest(`http://localhost/api/files/public/${token}/otp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email }), + }) +const put = (email: string, otp: string, token = 'tok_1') => + new NextRequest(`http://localhost/api/files/public/${token}/otp`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, otp }), + }) + +const emailShare = { + share: { id: 'sh_1', authType: 'email', password: null, allowedEmails: ['@acme.com'] }, + file: { originalName: 'report.pdf' }, +} + +describe('POST /api/files/public/[token]/otp', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimitDirect.mockResolvedValue({ allowed: true }) + mockResolveActiveShareByToken.mockResolvedValue(emailShare) + mockIsEmailAllowed.mockReturnValue(true) + mockGenerateOTP.mockReturnValue('123456') + mockRenderOTPEmail.mockResolvedValue('') + mockSendEmail.mockResolvedValue({ success: true }) + }) + + it('sends a code to an allow-listed email', async () => { + const res = await POST(post('user@acme.com'), params()) + expect(res.status).toBe(200) + expect(mockStoreOTP).toHaveBeenCalledWith('file', 'sh_1', 'user@acme.com', '123456') + expect(mockSendEmail).toHaveBeenCalled() + }) + + it('rejects an email not on the allow-list with 403', async () => { + mockIsEmailAllowed.mockReturnValueOnce(false) + const res = await POST(post('user@evil.com'), params()) + expect(res.status).toBe(403) + expect(mockStoreOTP).not.toHaveBeenCalled() + }) + + it('lowercases the email for allow-list matching and OTP storage', async () => { + await POST(post('User@ACME.com'), params()) + expect(mockIsEmailAllowed).toHaveBeenCalledWith('user@acme.com', expect.anything()) + expect(mockStoreOTP).toHaveBeenCalledWith('file', 'sh_1', 'user@acme.com', '123456') + }) + + it('rejects a non-email share with 400', async () => { + mockResolveActiveShareByToken.mockResolvedValueOnce({ + ...emailShare, + share: { ...emailShare.share, authType: 'password' }, + }) + const res = await POST(post('user@acme.com'), params()) + expect(res.status).toBe(400) + }) + + it('returns 429 when the IP rate limit is exceeded', async () => { + mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 }) + const res = await POST(post('user@acme.com'), params()) + expect(res.status).toBe(429) + expect(res.headers.get('Retry-After')).toBe('1') + }) +}) + +describe('PUT /api/files/public/[token]/otp', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveActiveShareByToken.mockResolvedValue(emailShare) + mockGetOTP.mockResolvedValue('123456:0') + mockDecodeOTPValue.mockReturnValue({ otp: '123456', attempts: 0 }) + }) + + it('verifies a correct code, sets the cookie, returns authType', async () => { + const res = await PUT(put('user@acme.com', '123456'), params()) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ authType: 'email' }) + expect(mockDeleteOTP).toHaveBeenCalledWith('file', 'sh_1', 'user@acme.com') + expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith( + expect.anything(), + 'file', + 'sh_1', + 'email', + null + ) + }) + + it('rejects a wrong code with 400 and increments attempts', async () => { + mockIncrementOTPAttempts.mockResolvedValueOnce('incremented') + const res = await PUT(put('user@acme.com', '000000'), params()) + expect(res.status).toBe(400) + expect(mockIncrementOTPAttempts).toHaveBeenCalled() + expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() + }) + + it('returns 429 when attempts are exhausted on a wrong code', async () => { + mockIncrementOTPAttempts.mockResolvedValueOnce('locked') + const res = await PUT(put('user@acme.com', '000000'), params()) + expect(res.status).toBe(429) + }) + + it('returns 400 when no code was issued', async () => { + mockGetOTP.mockResolvedValueOnce(null) + const res = await PUT(put('user@acme.com', '123456'), params()) + expect(res.status).toBe(400) + }) +}) diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts new file mode 100644 index 00000000000..c7257db0d12 --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -0,0 +1,194 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { renderOTPEmail } from '@/components/emails' +import { + requestPublicFileOtpContract, + verifyPublicFileOtpContract, +} from '@/lib/api/contracts/public-shares' +import { parseRequest } from '@/lib/api/server' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { isEmailAllowed, setDeploymentAuthCookie } from '@/lib/core/security/deployment' +import { + decodeOTPValue, + deleteOTP, + generateOTP, + getOTP, + incrementOTPAttempts, + MAX_OTP_ATTEMPTS, + OTP_EMAIL_RATE_LIMIT, + OTP_IP_RATE_LIMIT, + storeOTP, +} from '@/lib/core/security/otp' +import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('PublicFileOtpAPI') + +const rateLimiter = new RateLimiter() + +const SHARE_EMAIL_LABEL = 'a shared file' + +/** Allow-list for an email-gated share, read off the resolved row. */ +function shareAllowedEmails(allowedEmails: unknown): string[] { + return Array.isArray(allowedEmails) ? (allowedEmails as string[]) : [] +} + +function rateLimited(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { + const response = NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429 } + ) + response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000))) + return response +} + +/** + * POST /api/files/public/[token]/otp + * Sends a 6-digit verification code to an allow-listed email for an email-gated share. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const requestId = generateRequestId() + + try { + const ip = getClientIp(request) + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `file-otp:ip:${ip}`, + OTP_IP_RATE_LIMIT + ) + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] OTP IP rate limit exceeded from ${ip}`) + return rateLimited(ipRateLimit.retryAfterMs, OTP_IP_RATE_LIMIT.refillIntervalMs) + } + + const parsed = await parseRequest(requestPublicFileOtpContract, request, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params + // Normalize once so allow-list matching, OTP storage, and the verify lookup + // all key off the same value (allow-list entries are stored lowercase). + const email = parsed.data.body.email.trim().toLowerCase() + + const resolved = await resolveActiveShareByToken(token) + if (!resolved) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + if (resolved.share.authType !== 'email') { + return NextResponse.json( + { error: 'This file does not use email authentication' }, + { status: 400 } + ) + } + + if (!isEmailAllowed(email, shareAllowedEmails(resolved.share.allowedEmails))) { + return NextResponse.json({ error: 'Email not authorized for this file' }, { status: 403 }) + } + + const emailRateLimit = await rateLimiter.checkRateLimitDirect( + `file-otp:email:${resolved.share.id}:${email}`, + OTP_EMAIL_RATE_LIMIT + ) + if (!emailRateLimit.allowed) { + logger.warn(`[${requestId}] OTP email rate limit exceeded for ${email}`) + return rateLimited(emailRateLimit.retryAfterMs, OTP_EMAIL_RATE_LIMIT.refillIntervalMs) + } + + const otp = generateOTP() + await storeOTP('file', resolved.share.id, email, otp) + + const emailHtml = await renderOTPEmail(otp, email, 'email-verification', SHARE_EMAIL_LABEL) + const emailResult = await sendEmail({ + to: email, + subject: `Verification code for ${SHARE_EMAIL_LABEL}`, + html: emailHtml, + }) + if (!emailResult.success) { + logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) + return NextResponse.json({ error: 'Failed to send verification email' }, { status: 500 }) + } + + logger.info(`[${requestId}] OTP sent for share ${resolved.share.id}`) + return NextResponse.json({ message: 'Verification code sent' }) + } catch (error) { + logger.error(`[${requestId}] Error processing OTP request:`, error) + return NextResponse.json({ error: 'Failed to process request' }, { status: 500 }) + } + } +) + +/** + * PUT /api/files/public/[token]/otp + * Verifies the code and, on success, sets the `file_auth_{shareId}` cookie. + */ +export const PUT = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const requestId = generateRequestId() + + try { + const parsed = await parseRequest(verifyPublicFileOtpContract, request, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params + const { otp } = parsed.data.body + const email = parsed.data.body.email.trim().toLowerCase() + + const resolved = await resolveActiveShareByToken(token) + if (!resolved) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + if (resolved.share.authType !== 'email') { + return NextResponse.json( + { error: 'This file does not use email authentication' }, + { status: 400 } + ) + } + + const storedValue = await getOTP('file', resolved.share.id, email) + if (!storedValue) { + return NextResponse.json( + { error: 'No verification code found, request a new one' }, + { status: 400 } + ) + } + + const { otp: storedOTP, attempts } = decodeOTPValue(storedValue) + if (attempts >= MAX_OTP_ATTEMPTS) { + await deleteOTP('file', resolved.share.id, email) + return NextResponse.json( + { error: 'Too many failed attempts. Please request a new code.' }, + { status: 429 } + ) + } + + if (storedOTP !== otp) { + const result = await incrementOTPAttempts('file', resolved.share.id, email, storedValue) + if (result === 'locked') { + return NextResponse.json( + { error: 'Too many failed attempts. Please request a new code.' }, + { status: 429 } + ) + } + return NextResponse.json({ error: 'Invalid verification code' }, { status: 400 }) + } + + await deleteOTP('file', resolved.share.id, email) + + const response = NextResponse.json({ authType: resolved.share.authType }) + setDeploymentAuthCookie( + response, + 'file', + resolved.share.id, + resolved.share.authType, + resolved.share.password + ) + logger.info(`[${requestId}] OTP verified for share ${resolved.share.id}`) + return response + } catch (error) { + logger.error(`[${requestId}] Error verifying OTP:`, error) + return NextResponse.json({ error: 'Failed to process request' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/files/public/[token]/route.test.ts b/apps/sim/app/api/files/public/[token]/route.test.ts new file mode 100644 index 00000000000..aa32176c87c --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/route.test.ts @@ -0,0 +1,192 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockResolveActiveShareByToken, + mockEnforceRateLimit, + mockValidateDeploymentAuth, + mockSetDeploymentAuthCookie, +} = vi.hoisted(() => ({ + mockResolveActiveShareByToken: vi.fn(), + mockEnforceRateLimit: vi.fn(), + mockValidateDeploymentAuth: vi.fn(), + mockSetDeploymentAuthCookie: vi.fn(), +})) + +vi.mock('@/lib/public-shares/share-manager', () => ({ + resolveActiveShareByToken: mockResolveActiveShareByToken, +})) + +vi.mock('@/lib/public-shares/rate-limit', () => ({ + enforcePublicFileRateLimit: mockEnforceRateLimit, +})) + +vi.mock('@/lib/core/security/deployment-auth', () => ({ + validateDeploymentAuth: mockValidateDeploymentAuth, +})) + +vi.mock('@/lib/core/security/deployment', () => ({ + setDeploymentAuthCookie: mockSetDeploymentAuthCookie, +})) + +import { NextResponse } from 'next/server' +import { GET, POST } from '@/app/api/files/public/[token]/route' + +const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) }) +const request = (token = 'tok_1') => new NextRequest(`http://localhost/api/files/public/${token}`) +const postRequest = (password: string, token = 'tok_1') => + new NextRequest(`http://localhost/api/files/public/${token}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password }), + }) + +const publicShare = { + share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null }, + file: { + id: 'wf_1', + key: 'workspace/ws/secret-key.pdf', + workspaceId: 'ws-secret', + originalName: 'report.pdf', + contentType: 'application/pdf', + size: 2048, + }, + workspaceName: 'Acme Workspace', + ownerName: 'Jane Doe', +} + +const passwordShare = { + ...publicShare, + share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' }, +} + +describe('GET /api/files/public/[token]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnforceRateLimit.mockResolvedValue(null) // allow by default + mockValidateDeploymentAuth.mockResolvedValue({ authorized: true }) // public by default + }) + + it('returns 429 when the per-IP rate limit is exceeded', async () => { + mockEnforceRateLimit.mockResolvedValueOnce( + NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }) + ) + const res = await GET(request(), params()) + expect(res.status).toBe(429) + expect(mockResolveActiveShareByToken).not.toHaveBeenCalled() + }) + + it('returns 404 for an unknown or inactive token', async () => { + mockResolveActiveShareByToken.mockResolvedValueOnce(null) + const res = await GET(request(), params()) + expect(res.status).toBe(404) + }) + + it('returns public-safe metadata without leaking the key or workspace id', async () => { + mockResolveActiveShareByToken.mockResolvedValueOnce(publicShare) + const res = await GET(request(), params()) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + token: 'tok_1', + name: 'report.pdf', + type: 'application/pdf', + size: 2048, + workspaceName: 'Acme Workspace', + ownerName: 'Jane Doe', + }) + expect(JSON.stringify(body)).not.toContain('secret-key') + expect(JSON.stringify(body)).not.toContain('ws-secret') + }) + + it('returns 401 auth_required_password for a password share without a valid cookie', async () => { + mockResolveActiveShareByToken.mockResolvedValueOnce(passwordShare) + mockValidateDeploymentAuth.mockResolvedValueOnce({ + authorized: false, + error: 'auth_required_password', + }) + const res = await GET(request(), params()) + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('auth_required_password') + expect(mockValidateDeploymentAuth).toHaveBeenCalledWith( + expect.any(String), + passwordShare.share, + expect.anything(), + undefined, + 'file' + ) + }) + + it('serves metadata for a password share once authorized by cookie', async () => { + mockResolveActiveShareByToken.mockResolvedValueOnce(passwordShare) + mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true }) + const res = await GET(request(), params()) + expect(res.status).toBe(200) + expect((await res.json()).name).toBe('report.pdf') + }) +}) + +describe('POST /api/files/public/[token]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveActiveShareByToken.mockResolvedValue(passwordShare) + }) + + it('sets the file_auth cookie and returns the authType on a correct password', async () => { + mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true }) + const res = await POST(postRequest('hunter2'), params()) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ authType: 'password' }) + expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith( + expect.anything(), + 'file', + 'sh_1', + 'password', + 'enc:secret' + ) + }) + + it('refuses to mint a cookie for a non-password (e.g. public) share', async () => { + mockResolveActiveShareByToken.mockResolvedValueOnce({ + ...passwordShare, + share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null }, + }) + const res = await POST(postRequest('whatever'), params()) + expect(res.status).toBe(400) + expect(mockValidateDeploymentAuth).not.toHaveBeenCalled() + expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() + }) + + it('returns 401 Invalid password on mismatch without setting a cookie', async () => { + mockValidateDeploymentAuth.mockResolvedValueOnce({ + authorized: false, + error: 'Invalid password', + }) + const res = await POST(postRequest('wrong'), params()) + expect(res.status).toBe(401) + expect((await res.json()).error).toBe('Invalid password') + expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() + }) + + it('returns 429 with Retry-After when password attempts are rate-limited', async () => { + mockValidateDeploymentAuth.mockResolvedValueOnce({ + authorized: false, + error: 'Too many attempts. Please try again later.', + status: 429, + retryAfterMs: 60_000, + }) + const res = await POST(postRequest('wrong'), params()) + expect(res.status).toBe(429) + expect(res.headers.get('Retry-After')).toBe('60') + expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() + }) + + it('returns 404 for an unknown token', async () => { + mockResolveActiveShareByToken.mockResolvedValueOnce(null) + const res = await POST(postRequest('hunter2'), params()) + expect(res.status).toBe(404) + }) +}) diff --git a/apps/sim/app/api/files/public/[token]/route.ts b/apps/sim/app/api/files/public/[token]/route.ts new file mode 100644 index 00000000000..5c4482b22a9 --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/route.ts @@ -0,0 +1,143 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { + authenticatePublicFileContract, + getPublicFileContract, +} from '@/lib/api/contracts/public-shares' +import { parseRequest } from '@/lib/api/server' +import { setDeploymentAuthCookie } from '@/lib/core/security/deployment' +import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' +import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('PublicFileMetadataAPI') + +/** + * GET /api/files/public/[token] + * Public, unauthenticated metadata for a shared file. Returns 404 for unknown, + * inactive, or deleted shares — the existence of a file is never leaked. A + * password-protected share returns 401 `auth_required_password` until a valid + * `file_auth_{shareId}` cookie is present. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const requestId = generateRequestId() + + try { + const limited = await enforcePublicFileRateLimit(request, 'metadata') + if (limited) return limited + + const parsed = await parseRequest(getPublicFileContract, request, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params + + const resolved = await resolveActiveShareByToken(token) + if (!resolved) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const auth = await validateDeploymentAuth( + requestId, + resolved.share, + request, + undefined, + 'file' + ) + if (!auth.authorized) { + return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) + } + + const { file, workspaceName, ownerName } = resolved + return NextResponse.json({ + token, + name: file.originalName, + type: file.contentType, + size: file.size, + workspaceName, + ownerName, + }) + } catch (error) { + logger.error('Error fetching public file metadata:', error) + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to fetch file') }, + { status: 500 } + ) + } + } +) + +/** + * POST /api/files/public/[token] + * Exchanges a share password for a `file_auth_{shareId}` cookie. IP rate-limited + * via the shared deployment-auth gate; returns 401 (`Invalid password`) on + * mismatch and 429 (with `Retry-After`) when throttled. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const requestId = generateRequestId() + + try { + const parsed = await parseRequest(authenticatePublicFileContract, request, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params + const { password } = parsed.data.body + + const resolved = await resolveActiveShareByToken(token) + if (!resolved) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + // This endpoint authenticates password shares only. Refusing other modes + // here prevents minting a `file_auth` cookie for a `public` share (which + // `validateDeploymentAuth` would otherwise authorize), which could later + // satisfy the gate if the share is switched to `email`/`sso`. + if (resolved.share.authType !== 'password') { + return NextResponse.json( + { error: 'This file does not use password authentication' }, + { status: 400 } + ) + } + + const auth = await validateDeploymentAuth( + requestId, + resolved.share, + request, + { password }, + 'file' + ) + if (!auth.authorized) { + const response = NextResponse.json( + { error: auth.error ?? 'Invalid password' }, + { status: auth.status ?? 401 } + ) + if (auth.status === 429 && auth.retryAfterMs !== undefined) { + response.headers.set('Retry-After', String(Math.ceil(auth.retryAfterMs / 1000))) + } + return response + } + + const response = NextResponse.json({ authType: resolved.share.authType }) + setDeploymentAuthCookie( + response, + 'file', + resolved.share.id, + resolved.share.authType, + resolved.share.password + ) + logger.info('Public file share password accepted', { token, shareId: resolved.share.id }) + return response + } catch (error) { + logger.error('Error authenticating public file share:', error) + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to authenticate') }, + { status: 500 } + ) + } + } +) diff --git a/apps/sim/app/api/files/public/[token]/sso/route.test.ts b/apps/sim/app/api/files/public/[token]/sso/route.test.ts new file mode 100644 index 00000000000..92d78cd8b13 --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/sso/route.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveActiveShareByToken, mockIsEmailAllowed, mockCheckRateLimitDirect } = vi.hoisted( + () => ({ + mockResolveActiveShareByToken: vi.fn(), + mockIsEmailAllowed: vi.fn(), + mockCheckRateLimitDirect: vi.fn(), + }) +) + +vi.mock('@/lib/public-shares/share-manager', () => ({ + resolveActiveShareByToken: mockResolveActiveShareByToken, +})) +vi.mock('@/lib/core/security/deployment', () => ({ isEmailAllowed: mockIsEmailAllowed })) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mockCheckRateLimitDirect + }, +})) + +import { POST } from '@/app/api/files/public/[token]/sso/route' + +const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) }) +const post = (email: string, token = 'tok_1') => + new NextRequest(`http://localhost/api/files/public/${token}/sso`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email }), + }) + +const ssoShare = { + share: { id: 'sh_1', authType: 'sso', password: null, allowedEmails: ['@acme.com'] }, + file: { originalName: 'report.pdf' }, +} + +describe('POST /api/files/public/[token]/sso', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimitDirect.mockResolvedValue({ allowed: true }) + mockResolveActiveShareByToken.mockResolvedValue(ssoShare) + }) + + it('returns eligible:true for an allow-listed email', async () => { + mockIsEmailAllowed.mockReturnValueOnce(true) + const res = await POST(post('user@acme.com'), params()) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ eligible: true }) + }) + + it('returns eligible:false for a non-listed email', async () => { + mockIsEmailAllowed.mockReturnValueOnce(false) + const res = await POST(post('user@evil.com'), params()) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ eligible: false }) + }) + + it('rejects a non-sso share with 400', async () => { + mockResolveActiveShareByToken.mockResolvedValueOnce({ + ...ssoShare, + share: { ...ssoShare.share, authType: 'email' }, + }) + const res = await POST(post('user@acme.com'), params()) + expect(res.status).toBe(400) + }) + + it('returns 404 for an unknown token', async () => { + mockResolveActiveShareByToken.mockResolvedValueOnce(null) + const res = await POST(post('user@acme.com'), params()) + expect(res.status).toBe(404) + }) + + it('returns 429 when rate-limited', async () => { + mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 2000 }) + const res = await POST(post('user@acme.com'), params()) + expect(res.status).toBe(429) + expect(res.headers.get('Retry-After')).toBe('2') + }) +}) diff --git a/apps/sim/app/api/files/public/[token]/sso/route.ts b/apps/sim/app/api/files/public/[token]/sso/route.ts new file mode 100644 index 00000000000..508c94777ab --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/sso/route.ts @@ -0,0 +1,71 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { publicFileSSOContract } from '@/lib/api/contracts/public-shares' +import { parseRequest } from '@/lib/api/server' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { isEmailAllowed } from '@/lib/core/security/deployment' +import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('PublicFileSSOAPI') + +const rateLimiter = new RateLimiter() + +const SSO_IP_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 20, + refillRate: 20, + refillIntervalMs: 15 * 60_000, +} + +/** + * POST /api/files/public/[token]/sso + * Reports whether an email is on the allow-list for an SSO-gated share. The actual + * authentication is the global Sim session (checked at the page/route gate). + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const requestId = generateRequestId() + + const ip = getClientIp(request) + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `file-sso:ip:${ip}`, + SSO_IP_RATE_LIMIT + ) + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`) + const response = NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429 } + ) + response.headers.set( + 'Retry-After', + String(Math.ceil((ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000)) + ) + return response + } + + const parsed = await parseRequest(publicFileSSOContract, request, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params + const email = parsed.data.body.email.trim().toLowerCase() + + const resolved = await resolveActiveShareByToken(token) + if (!resolved) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + if (resolved.share.authType !== 'sso') { + return NextResponse.json({ error: 'This file is not configured for SSO' }, { status: 400 }) + } + + const allowedEmails = Array.isArray(resolved.share.allowedEmails) + ? (resolved.share.allowedEmails as string[]) + : [] + return NextResponse.json({ eligible: isEmailAllowed(email, allowedEmails) }) + } +) diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index cc02764e2f4..5e0df625558 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -2,8 +2,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { FolderLockedError } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' -import { FolderLockedError } from '@sim/workflow-authz' import { and, eq, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { duplicateFolderContract } from '@/lib/api/contracts' diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 26d5f218005..48483258eb1 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateFolderContract } from '@/lib/api/contracts' diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index 274e2bc7784..b361abf6df1 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { reorderFoldersContract } from '@/lib/api/contracts' diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index baafe6fd2ad..f7eb512da68 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -394,7 +394,7 @@ describe('Folders API Route', () => { it('should reject creating a subfolder inside a locked parent folder', async () => { mockAuthenticatedUser() - const { FolderLockedError } = await import('@sim/workflow-authz') + const { FolderLockedError } = await import('@sim/platform-authz/workflow') workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValueOnce( new FolderLockedError('Folder is locked') ) diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index f359e376542..d9206b0caeb 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createFolderContract, listFoldersContract } from '@/lib/api/contracts' diff --git a/apps/sim/app/api/guardrails/validate/route.ts b/apps/sim/app/api/guardrails/validate/route.ts index c2754b79db9..ca50b20b2d2 100644 --- a/apps/sim/app/api/guardrails/validate/route.ts +++ b/apps/sim/app/api/guardrails/validate/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { type NextRequest, NextResponse } from 'next/server' import { guardrailsValidateContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/jobs/[jobId]/route.test.ts b/apps/sim/app/api/jobs/[jobId]/route.test.ts index 0dceacd56eb..189e03b1052 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.test.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.test.ts @@ -15,7 +15,7 @@ vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue, })) -vi.mock('@sim/workflow-authz', () => ({ +vi.mock('@sim/platform-authz/workflow', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, })) diff --git a/apps/sim/app/api/jobs/[jobId]/route.ts b/apps/sim/app/api/jobs/[jobId]/route.ts index adba3ec4d5d..01677e506ee 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.ts @@ -37,7 +37,9 @@ export const GET = withRouteHandler( const metadataToCheck = job.metadata if (metadataToCheck?.workflowId) { - const { authorizeWorkflowByWorkspacePermission } = await import('@sim/workflow-authz') + const { authorizeWorkflowByWorkspacePermission } = await import( + '@sim/platform-authz/workflow' + ) const accessCheck = await authorizeWorkflowByWorkspacePermission({ userId: authenticatedUserId, workflowId: metadataToCheck.workflowId as string, diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index 8e80e41f6e3..059abee2fb6 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { bulkKnowledgeChunksContract, diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 371fc1512c7..dd10a328d27 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -1,8 +1,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { bulkKnowledgeDocumentsContract, diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts index 40220dd0732..d1c3af79f73 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts @@ -2,9 +2,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { document } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { upsertKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge' diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 9dd40280f82..021f3059e36 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { knowledgeSearchBodySchema } from '@/lib/api/contracts/knowledge' import { parseJsonBody, validationErrorResponse } from '@/lib/api/server' diff --git a/apps/sim/app/api/logs/execution/[executionId]/route.ts b/apps/sim/app/api/logs/execution/[executionId]/route.ts index adab287bf99..82e33a644c9 100644 --- a/apps/sim/app/api/logs/execution/[executionId]/route.ts +++ b/apps/sim/app/api/logs/execution/[executionId]/route.ts @@ -1,13 +1,12 @@ import { db } from '@sim/db' import { jobExecutionLogs, - permissions, workflow, workflowExecutionLogs, workflowExecutionSnapshots, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { executionIdParamsSchema } from '@/lib/api/contracts/logs' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' @@ -15,6 +14,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('LogsByExecutionIdAPI') @@ -52,22 +52,23 @@ export const GET = withRouteHandler( }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, authenticatedUserId) - ) - ) .where(eq(workflowExecutionLogs.executionId, executionId)) .limit(1) + if ( + workflowLog && + !(await checkWorkspaceAccess(workflowLog.workspaceId, authenticatedUserId)).hasAccess + ) { + logger.warn(`[${requestId}] Execution access denied: ${executionId}`) + return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) + } + // Fallback: check job_execution_logs if (!workflowLog) { const [jobLog] = await db .select({ id: jobExecutionLogs.id, + workspaceId: jobExecutionLogs.workspaceId, executionId: jobExecutionLogs.executionId, trigger: jobExecutionLogs.trigger, startedAt: jobExecutionLogs.startedAt, @@ -77,18 +78,13 @@ export const GET = withRouteHandler( executionData: jobExecutionLogs.executionData, }) .from(jobExecutionLogs) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, jobExecutionLogs.workspaceId), - eq(permissions.userId, authenticatedUserId) - ) - ) .where(eq(jobExecutionLogs.executionId, executionId)) .limit(1) - if (!jobLog) { + if ( + !jobLog || + !(await checkWorkspaceAccess(jobLog.workspaceId, authenticatedUserId)).hasAccess + ) { logger.warn(`[${requestId}] Execution not found or access denied: ${executionId}`) return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index 560eee71618..a2006538fd9 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -1,5 +1,5 @@ import { dbReplica } from '@sim/db' -import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, desc, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -10,6 +10,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('LogsExportAPI') @@ -72,6 +73,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 'traceSpans', ].join(',') + const access = await checkWorkspaceAccess(params.workspaceId, userId) + if (!access.hasAccess) { + return new NextResponse(`${header}\n`, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="logs-export.csv"', + 'Cache-Control': 'no-cache', + }, + }) + } + const encoder = new TextEncoder() const stream = new ReadableStream({ start: async (controller) => { @@ -84,14 +97,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .select(selectColumns) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where(conditions) .orderBy(desc(workflowExecutionLogs.startedAt)) .limit(pageSize) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 359a8b7505d..88f33ff6b54 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -1,5 +1,5 @@ import { dbReplica } from '@sim/db' -import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -15,6 +15,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('LogsStatsAPI') @@ -36,6 +37,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { searchParams } = new URL(request.url) const params = statsQueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) + const access = await checkWorkspaceAccess(params.workspaceId, userId) + if (!access.hasAccess) { + return NextResponse.json( + { + workflows: [], + aggregateSegments: [], + totalRuns: 0, + totalErrors: 0, + avgLatency: 0, + timeBounds: { start: new Date().toISOString(), end: new Date().toISOString() }, + segmentMs: 0, + } satisfies DashboardStatsResponse, + { status: 200 } + ) + } + const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId) if (params.folderIds) { @@ -55,14 +72,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where(whereCondition) const bounds = boundsQuery[0] @@ -103,14 +112,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where(whereCondition) .groupBy( sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, diff --git a/apps/sim/app/api/logs/triggers/route.ts b/apps/sim/app/api/logs/triggers/route.ts index 1ebe834b6f9..2b033384eca 100644 --- a/apps/sim/app/api/logs/triggers/route.ts +++ b/apps/sim/app/api/logs/triggers/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { permissions, workflowExecutionLogs } from '@sim/db/schema' +import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNotNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -8,6 +8,7 @@ import { searchParamsToObject, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('TriggersAPI') @@ -40,19 +41,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const params = validation.data + const access = await checkWorkspaceAccess(params.workspaceId, userId) + if (!access.hasAccess) { + return NextResponse.json({ triggers: [], count: 0 }) + } + const triggers = await db .selectDistinct({ trigger: workflowExecutionLogs.trigger, }) .from(workflowExecutionLogs) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) .where( and( eq(workflowExecutionLogs.workspaceId, params.workspaceId), diff --git a/apps/sim/app/api/mcp/discover/route.ts b/apps/sim/app/api/mcp/discover/route.ts index 5c63714b0a0..222a50e70d4 100644 --- a/apps/sim/app/api/mcp/discover/route.ts +++ b/apps/sim/app/api/mcp/discover/route.ts @@ -1,11 +1,12 @@ import { db } from '@sim/db' -import { permissions, workflowMcpServer, workspace } from '@sim/db/schema' +import { workflowMcpServer, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' const logger = createLogger('McpDiscoverAPI') @@ -34,24 +35,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - const userWorkspacePermissions = await db - .select({ entityId: permissions.entityId }) - .from(permissions) - .innerJoin(workspace, eq(permissions.entityId, workspace.id)) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - isNull(workspace.archivedAt) - ) - ) + const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId) + const accessibleWorkspaceIds = accessibleRows.map((row) => row.workspace.id) const workspaceIds = auth.apiKeyType === 'workspace' && auth.workspaceId - ? userWorkspacePermissions - .map((w) => w.entityId) - .filter((workspaceId) => workspaceId === auth.workspaceId) - : userWorkspacePermissions.map((w) => w.entityId) + ? accessibleWorkspaceIds.filter((workspaceId) => workspaceId === auth.workspaceId) + : accessibleWorkspaceIds if (workspaceIds.length === 0) { return NextResponse.json({ success: true, servers: [] }) diff --git a/apps/sim/app/api/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/organizations/[id]/data-retention/route.ts index 65e291a00d3..37fbbaabb94 100644 --- a/apps/sim/app/api/organizations/[id]/data-retention/route.ts +++ b/apps/sim/app/api/organizations/[id]/data-retention/route.ts @@ -1,37 +1,41 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' +import type { DataRetentionSettings } from '@sim/db/schema' import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { updateOrganizationDataRetentionContract } from '@/lib/api/contracts/organization' +import { + type OrganizationRetentionValues, + updateOrganizationDataRetentionContract, +} from '@/lib/api/contracts/organization' import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { - CLEANUP_CONFIG, - type OrganizationRetentionSettings, -} from '@/lib/billing/cleanup-dispatcher' +import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('DataRetentionAPI') -function enterpriseDefaults(): OrganizationRetentionSettings { +function enterpriseDefaults(): OrganizationRetentionValues { return { logRetentionHours: CLEANUP_CONFIG['cleanup-logs'].defaults.enterprise, softDeleteRetentionHours: CLEANUP_CONFIG['cleanup-soft-deletes'].defaults.enterprise, taskCleanupHours: CLEANUP_CONFIG['cleanup-tasks'].defaults.enterprise, + piiRedaction: null, } } function normalizeConfigured( - settings: Partial | null | undefined -): OrganizationRetentionSettings { + settings: DataRetentionSettings | null | undefined +): OrganizationRetentionValues { return { logRetentionHours: settings?.logRetentionHours ?? null, softDeleteRetentionHours: settings?.softDeleteRetentionHours ?? null, taskCleanupHours: settings?.taskCleanupHours ?? null, + piiRedaction: settings?.piiRedaction?.rules ? { rules: settings.piiRedaction.rules } : null, } } @@ -73,6 +77,7 @@ export const GET = withRouteHandler( } const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId)) + const piiRedactionEnabled = await isFeatureEnabled('pii-redaction') const configured = normalizeConfigured(org.dataRetentionSettings) const defaults = enterpriseDefaults() @@ -83,6 +88,7 @@ export const GET = withRouteHandler( defaults, configured, effective: isEnterprise ? configured : defaults, + piiRedactionEnabled, }, }) } @@ -151,8 +157,10 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) } + const piiRedactionEnabled = await isFeatureEnabled('pii-redaction') + const current = normalizeConfigured(currentOrg.dataRetentionSettings) - const merged: OrganizationRetentionSettings = { ...current } + const merged: DataRetentionSettings = { ...current } if (body.logRetentionHours !== undefined) { merged.logRetentionHours = body.logRetentionHours } @@ -162,6 +170,15 @@ export const PUT = withRouteHandler( if (body.taskCleanupHours !== undefined) { merged.taskCleanupHours = body.taskCleanupHours } + if (body.piiRedaction !== undefined) { + if (!piiRedactionEnabled) { + return NextResponse.json( + { error: 'PII redaction is not enabled for this organization' }, + { status: 403 } + ) + } + merged.piiRedaction = body.piiRedaction + } const [updated] = await db .update(organization) @@ -197,6 +214,7 @@ export const PUT = withRouteHandler( defaults, configured, effective: configured, + piiRedactionEnabled, }, }) } diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts index c069e3c950e..43f4ff6ce8b 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts @@ -12,6 +12,7 @@ const { mockCreatePendingInvitation, mockSendInvitationEmail, mockCancelPendingInvitation, + mockGrantWorkspaceAccessDirectly, } = vi.hoisted(() => ({ mockDbState: { selectResults: [] as any[], @@ -22,6 +23,7 @@ const { mockCreatePendingInvitation: vi.fn(), mockSendInvitationEmail: vi.fn(), mockCancelPendingInvitation: vi.fn(), + mockGrantWorkspaceAccessDirectly: vi.fn(), })) function createSelectChain() { @@ -115,6 +117,10 @@ vi.mock('@/lib/invitations/send', () => ({ cancelPendingInvitation: mockCancelPendingInvitation, })) +vi.mock('@/lib/invitations/direct-grant', () => ({ + grantWorkspaceAccessDirectly: mockGrantWorkspaceAccessDirectly, +})) + vi.mock('@/lib/messaging/email/validation', () => ({ quickValidateEmail: vi.fn((email: string) => ({ isValid: email.includes('@') })), })) @@ -151,6 +157,7 @@ describe('POST /api/organizations/[id]/invitations', () => { expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }) mockSendInvitationEmail.mockResolvedValue({ success: true }) + mockGrantWorkspaceAccessDirectly.mockResolvedValue({ outcome: 'added', permission: 'write' }) }) it('creates a unified invitation and sends a single email', async () => { @@ -191,15 +198,15 @@ describe('POST /api/organizations/[id]/invitations', () => { expect(mockCancelPendingInvitation).not.toHaveBeenCalled() }) - it('sends a workspace invitation to an existing member for selected workspaces they lack', async () => { + it('adds an existing member directly to selected workspaces they lack (no invitation/email)', async () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) mockDbState.selectResults = [ [{ role: 'owner' }], [{ name: 'Org One' }], - [{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ id: 'ws-2', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }], [{ userId: 'user-2', userEmail: 'member@example.com' }], [], [{ userId: 'user-2', workspaceId: 'ws-1' }], @@ -224,30 +231,111 @@ describe('POST /api/organizations/[id]/invitations', () => { ) expect(response.status).toBe(200) - expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1) - expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(1) + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith( expect.objectContaining({ - kind: 'workspace', + userId: 'user-2', email: 'member@example.com', + workspaceId: 'ws-2', + permission: 'write', organizationId: 'org-1', - membershipIntent: 'internal', - grants: [{ workspaceId: 'ws-2', permission: 'write' }], - }) - ) - expect(mockSendInvitationEmail).toHaveBeenCalledWith( - expect.objectContaining({ - kind: 'workspace', - email: 'member@example.com', - grants: [{ workspaceId: 'ws-2', permission: 'write' }], }) ) const body = await response.json() - expect(body.data.invitationsSent).toBe(1) - expect(body.data.invitedEmails).toEqual(['member@example.com']) + expect(body.data.invitationsSent).toBe(0) + expect(body.data.directlyAdded).toEqual(['member@example.com']) + expect(body.data.directlyAddedCount).toBe(1) expect(body.data.existingMembers).toEqual([]) }) + it('reports a partially-failed member only as added, never in both buckets', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + // First grant succeeds, second throws (e.g. transient DB error). + mockGrantWorkspaceAccessDirectly + .mockResolvedValueOnce({ outcome: 'added', permission: 'write' }) + .mockRejectedValueOnce(new Error('db blip')) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ userId: 'user-2', userEmail: 'member@example.com' }], + [], + [], + [], + [{ name: 'Owner', email: 'owner@example.com' }], + ] + + const response = await POST( + createMockRequest( + 'POST', + { + emails: ['member@example.com'], + workspaceInvitations: [ + { workspaceId: 'ws-1', permission: 'write' }, + { workspaceId: 'ws-2', permission: 'write' }, + ], + }, + {}, + 'http://localhost/api/organizations/org-1/invitations?batch=true' + ), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(200) + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(2) + const body = await response.json() + expect(body.data.directlyAdded).toEqual(['member@example.com']) + expect(body.data.failedInvitations).toEqual([]) + }) + + it('returns 207 with both successes and failures when one member is added and another fails', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + mockGrantWorkspaceAccessDirectly + .mockResolvedValueOnce({ outcome: 'added', permission: 'write' }) + .mockRejectedValueOnce(new Error('db blip')) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], + [ + { userId: 'user-a', userEmail: 'a@example.com' }, + { userId: 'user-b', userEmail: 'b@example.com' }, + ], + [], + [], + [], + [{ name: 'Owner', email: 'owner@example.com' }], + ] + + const response = await POST( + createMockRequest( + 'POST', + { + emails: ['a@example.com', 'b@example.com'], + workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'write' }], + }, + {}, + 'http://localhost/api/organizations/org-1/invitations?batch=true' + ), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(207) + const body = await response.json() + expect(body.success).toBe(false) + expect(body.data.directlyAdded).toEqual(['a@example.com']) + expect(body.data.directlyAddedCount).toBe(1) + expect(body.data.failedInvitations).toEqual([{ email: 'b@example.com', error: 'db blip' }]) + }) + it('returns 400 when an existing member already has access to every selected workspace', async () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) @@ -281,14 +369,14 @@ describe('POST /api/organizations/[id]/invitations', () => { expect(mockCreatePendingInvitation).not.toHaveBeenCalled() }) - it('invites new emails to the organization and existing members to workspaces in one batch', async () => { + it('invites new emails to the organization and adds existing members to workspaces in one batch', async () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) mockDbState.selectResults = [ [{ role: 'owner' }], [{ name: 'Org One' }], - [{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], [{ userId: 'user-2', userEmail: 'member@example.com' }], [], [], @@ -310,7 +398,7 @@ describe('POST /api/organizations/[id]/invitations', () => { ) expect(response.status).toBe(200) - expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(2) + expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1) expect(mockCreatePendingInvitation).toHaveBeenCalledWith( expect.objectContaining({ kind: 'organization', @@ -318,17 +406,21 @@ describe('POST /api/organizations/[id]/invitations', () => { grants: [{ workspaceId: 'ws-1', permission: 'read' }], }) ) - expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(1) + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith( expect.objectContaining({ - kind: 'workspace', + userId: 'user-2', email: 'member@example.com', - grants: [{ workspaceId: 'ws-1', permission: 'read' }], + workspaceId: 'ws-1', + permission: 'read', }) ) const body = await response.json() - expect(body.data.invitationsSent).toBe(2) - expect(body.data.invitedEmails).toEqual(['new@example.com', 'member@example.com']) + expect(body.data.invitationsSent).toBe(1) + expect(body.data.invitedEmails).toEqual(['new@example.com']) + expect(body.data.directlyAdded).toEqual(['member@example.com']) + expect(body.data.directlyAddedCount).toBe(1) }) it('still rejects existing members on the non-batch organization invite path', async () => { diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index de6bee05b77..0f954023a2d 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -10,6 +10,7 @@ import { workspace, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { getErrorMessage } from '@sim/utils/errors' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -26,6 +27,7 @@ import { validateSeatAvailability, } from '@/lib/billing/validation/seat-management' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { grantWorkspaceAccessDirectly } from '@/lib/invitations/direct-grant' import { cancelPendingInvitation, createPendingInvitation, @@ -78,7 +80,7 @@ export const GET = withRouteHandler( } const userRole = memberEntry.role - if (!['owner', 'admin'].includes(userRole)) { + if (!isOrgAdminRole(userRole)) { return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } @@ -148,7 +150,7 @@ export const POST = withRouteHandler( ) } - if (!['owner', 'admin'].includes(memberEntry.role)) { + if (!isOrgAdminRole(memberEntry.role)) { return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } @@ -188,6 +190,7 @@ export const POST = withRouteHandler( } const validGrants: WorkspaceGrantPayload[] = [] + const workspaceNameById = new Map() if (isBatch) { if (!Array.isArray(workspaceInvitations) || workspaceInvitations.length === 0) { return NextResponse.json( @@ -214,6 +217,7 @@ export const POST = withRouteHandler( const [workspaceEntry] = await db .select({ id: workspace.id, + name: workspace.name, organizationId: workspace.organizationId, workspaceMode: workspace.workspaceMode, }) @@ -241,6 +245,7 @@ export const POST = withRouteHandler( await validateInvitationsAllowed(session.user.id, wsInvitation.workspaceId) + workspaceNameById.set(workspaceEntry.id, workspaceEntry.name) validGrants.push({ workspaceId: wsInvitation.workspaceId, permission: wsInvitation.permission, @@ -422,65 +427,45 @@ export const POST = withRouteHandler( .limit(1) const inviterName = inviterRow?.name || inviterRow?.email || 'A user' + const failedInvitations: Array<{ email: string; error: string }> = [] + /** - * Organization invitations (new emails, all selected grants) and - * workspace invitations (existing members, only the grants they lack) - * share one create/send/rollback pipeline; they differ only in `kind`, - * grants, and audit treatment. + * Brand-new emails receive an organization invitation (with all selected + * workspace grants) that still requires acceptance — accepting is what + * joins them to the org and consumes a seat. */ - const pendingSends = [ - ...emailsToInvite.map((email) => ({ - kind: 'organization' as const, - email, - grants: validGrants, - })), - ...memberWorkspaceInvites.map((memberInvite) => ({ - kind: 'workspace' as const, - email: memberInvite.email, - grants: memberInvite.grants, - })), - ] - - const sentInvitations: Array<{ - id: string - email: string - kind: 'organization' | 'workspace' - workspaceIds: string[] - }> = [] - const failedInvitations: Array<{ email: string; error: string }> = [] + const sentInvitations: Array<{ id: string; email: string; workspaceIds: string[] }> = [] - for (const send of pendingSends) { - const sendRole = send.kind === 'organization' ? role : 'member' + for (const email of emailsToInvite) { try { const { invitationId, token } = await createPendingInvitation({ - kind: send.kind, - email: send.email, + kind: 'organization', + email, inviterId: session.user.id, organizationId, membershipIntent: 'internal', - role: sendRole, - grants: send.grants, + role, + grants: validGrants, }) const emailResult = await sendInvitationEmail({ invitationId, token, - kind: send.kind, - email: send.email, + kind: 'organization', + email, inviterName, organizationId, - organizationRole: sendRole, - grants: send.grants, + organizationRole: role, + grants: validGrants, }) if (!emailResult.success) { logger.error('Failed to send invitation email', { - kind: send.kind, - email: send.email, + email, error: emailResult.error, }) failedInvitations.push({ - email: send.email, + email, error: emailResult.error || 'Unknown email delivery error', }) await cancelPendingInvitation(invitationId) @@ -489,76 +474,98 @@ export const POST = withRouteHandler( sentInvitations.push({ id: invitationId, - email: send.email, - kind: send.kind, - workspaceIds: send.grants.map((grant) => grant.workspaceId), + email, + workspaceIds: validGrants.map((grant) => grant.workspaceId), }) } catch (creationError) { logger.error('Failed to create invitation', { - kind: send.kind, - email: send.email, + email, error: creationError, }) failedInvitations.push({ - email: send.email, + email, error: getErrorMessage(creationError, 'Failed to create invitation'), }) } } - for (const inv of sentInvitations) { - if (inv.kind === 'organization') { - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_INVITATION_CREATED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: organizationEntry.name, - description: `Invited ${inv.email} to organization as ${role}`, - metadata: { - invitationId: inv.id, - targetEmail: inv.email, - targetRole: role, - isBatch, - workspaceGrantCount: validGrants.length, - enforcedFixedSeats: enforceFixedSeats, - plan: orgSubscription?.plan ?? null, - }, - request, - }) - continue + /** + * Existing organization members are granted workspace access directly — + * no invitation, no acceptance step. They are already in the org, so no + * seat is consumed. The grant is idempotent and upgrades lower access. + */ + const directlyAdded: string[] = [] + + for (const memberInvite of memberWorkspaceInvites) { + const memberUserId = memberUserIdByEmail.get(memberInvite.email) + if (!memberUserId) continue + + let addedAny = false + let lastGrantError: string | null = null + for (const grant of memberInvite.grants) { + try { + const grantResult = await grantWorkspaceAccessDirectly({ + userId: memberUserId, + email: memberInvite.email, + workspaceId: grant.workspaceId, + workspaceName: workspaceNameById.get(grant.workspaceId) ?? 'a workspace', + permission: grant.permission, + organizationId, + actorId: session.user.id, + actorName: inviterName, + actorEmail: session.user.email, + request, + }) + + if (grantResult.outcome === 'added') addedAny = true + } catch (grantError) { + logger.error('Failed to grant workspace access directly', { + email: memberInvite.email, + workspaceId: grant.workspaceId, + error: grantError, + }) + lastGrantError = getErrorMessage(grantError, 'Failed to add member to workspace') + } } - for (const workspaceId of inv.workspaceIds) { - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.MEMBER_INVITED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: inv.email, - description: `Invited existing organization member ${inv.email} to workspace`, - metadata: { - invitationId: inv.id, - targetEmail: inv.email, - organizationId, - isBatch, - }, - request, - }) + if (addedAny) { + directlyAdded.push(memberInvite.email) + } else if (lastGrantError) { + failedInvitations.push({ email: memberInvite.email, error: lastGrantError }) } } - const sentOrgInvitations = sentInvitations.filter((inv) => inv.kind === 'organization') + for (const inv of sentInvitations) { + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_INVITATION_CREATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: organizationEntry.name, + description: `Invited ${inv.email} to organization as ${role}`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + targetRole: role, + isBatch, + workspaceGrantCount: validGrants.length, + enforcedFixedSeats: enforceFixedSeats, + plan: orgSubscription?.plan ?? null, + }, + request, + }) + } + const totalInvitationsSent = sentInvitations.length + const totalSucceeded = totalInvitationsSent + directlyAdded.length const responseData = { invitationsSent: totalInvitationsSent, invitedEmails: sentInvitations.map((inv) => inv.email), + directlyAdded, + directlyAddedCount: directlyAdded.length, failedInvitations, existingMembers: membersAlreadyCovered, pendingInvitations: processedEmails.filter( @@ -571,20 +578,25 @@ export const POST = withRouteHandler( ...(seatValidation ? { seatInfo: { - seatsUsed: seatValidation.currentSeats + sentOrgInvitations.length, + seatsUsed: seatValidation.currentSeats + totalInvitationsSent, maxSeats: seatValidation.maxSeats, - availableSeats: seatValidation.availableSeats - sentOrgInvitations.length, + availableSeats: seatValidation.availableSeats - totalInvitationsSent, }, } : {}), } - if (failedInvitations.length > 0 && totalInvitationsSent === 0) { + const summaryParts: string[] = [] + if (totalInvitationsSent > 0) summaryParts.push(`${totalInvitationsSent} invitation(s) sent`) + if (directlyAdded.length > 0) summaryParts.push(`${directlyAdded.length} member(s) added`) + const summary = summaryParts.join(', ') + + if (failedInvitations.length > 0 && totalSucceeded === 0) { return NextResponse.json( { success: false, - error: 'Failed to send invitation emails.', - message: 'No invitation emails could be delivered.', + error: 'Failed to send invitations.', + message: 'No invitations could be delivered.', data: responseData, }, { status: 502 } @@ -595,8 +607,8 @@ export const POST = withRouteHandler( return NextResponse.json( { success: false, - error: 'Some invitation emails failed to send.', - message: `${totalInvitationsSent} invitation(s) sent, ${failedInvitations.length} failed`, + error: 'Some invitations failed.', + message: `${summary}, ${failedInvitations.length} failed`, data: responseData, }, { status: 207 } @@ -605,7 +617,7 @@ export const POST = withRouteHandler( return NextResponse.json({ success: true, - message: `${totalInvitationsSent} invitation(s) sent successfully`, + message: `${summary || 'No changes'} successfully`, data: responseData, }) } catch (error) { diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index f6f3dd68944..69433ca0b7d 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, dbReplica } from '@sim/db' import { member, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateOrganizationMemberRoleContract } from '@/lib/api/contracts/organization' @@ -54,7 +55,7 @@ export const GET = withRouteHandler( } const userRole = userMember[0].role - const hasAdminAccess = ['owner', 'admin'].includes(userRole) + const hasAdminAccess = isOrgAdminRole(userRole) const memberQuery = db .select({ @@ -182,7 +183,7 @@ export const PUT = withRouteHandler( ) } - if (!['owner', 'admin'].includes(userMember[0].role)) { + if (!isOrgAdminRole(userMember[0].role)) { return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } @@ -306,7 +307,7 @@ export const DELETE = withRouteHandler( } const canRemoveMembers = - ['owner', 'admin'].includes(userMember[0].role) || session.user.id === targetUserId + isOrgAdminRole(userMember[0].role) || session.user.id === targetUserId if (!canRemoveMembers) { return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 }) diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts index e92f8e1ce6a..fcfb90ebc62 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts @@ -10,6 +10,7 @@ const { mockGetOrgMemberUsageLimit, mockGetOrgMemberWorkspaceUsage, mockSetOrgMemberUsageLimit, + mockGetOrganizationSubscription, mockFlags, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), @@ -17,6 +18,7 @@ const { mockGetOrgMemberUsageLimit: vi.fn(), mockGetOrgMemberWorkspaceUsage: vi.fn(), mockSetOrgMemberUsageLimit: vi.fn(), + mockGetOrganizationSubscription: vi.fn(), mockFlags: { isHosted: true }, })) @@ -37,6 +39,10 @@ vi.mock('@/lib/billing/organizations/member-limits', () => ({ setOrgMemberUsageLimit: mockSetOrgMemberUsageLimit, })) +vi.mock('@/lib/billing/core/billing', () => ({ + getOrganizationSubscription: mockGetOrganizationSubscription, +})) + vi.mock('@/lib/core/config/env-flags', () => ({ get isHosted() { return mockFlags.isHosted @@ -65,6 +71,7 @@ describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) mockGetOrgMemberWorkspaceUsage.mockResolvedValue(1) // $1 -> 200 credits mockGetOrgMemberUsageLimit.mockResolvedValue(2) // $2 -> 400 credits + mockGetOrganizationSubscription.mockResolvedValue(null) }) it('returns 401 without a session', async () => { @@ -93,6 +100,7 @@ describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { data: { creditsUsed: 200, creditLimit: 400, + billingInterval: 'month', }, }) }) @@ -103,6 +111,20 @@ describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { const body = await res.json() expect(body.data.creditLimit).toBeNull() }) + + it('reports a yearly billing interval from subscription metadata', async () => { + mockGetOrganizationSubscription.mockResolvedValue({ metadata: { billingInterval: 'year' } }) + const res = await GET(getRequest(), context()) + const body = await res.json() + expect(body.data.billingInterval).toBe('year') + }) + + it('prefers the billing_interval column when metadata lacks it', async () => { + mockGetOrganizationSubscription.mockResolvedValue({ billingInterval: 'year', metadata: {} }) + const res = await GET(getRequest(), context()) + const body = await res.json() + expect(body.data.billingInterval).toBe('year') + }) }) describe('PUT /api/organizations/[id]/members/[memberId]/usage-limit', () => { diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts index ad85d7ef83e..153da1db116 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts @@ -7,7 +7,9 @@ import { } from '@/lib/api/contracts/organization' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' +import { resolveBillingInterval } from '@/lib/billing/core/subscription' import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion' import { getOrgMemberUsageLimit, @@ -48,9 +50,10 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } - const [usage, limitDollars] = await Promise.all([ + const [usage, limitDollars, orgSubscription] = await Promise.all([ getOrgMemberWorkspaceUsage(organizationId, memberId), getOrgMemberUsageLimit(organizationId, memberId), + getOrganizationSubscription(organizationId), ]) return NextResponse.json({ @@ -58,6 +61,7 @@ export const GET = withRouteHandler( data: { creditsUsed: dollarsToCredits(usage), creditLimit: limitDollars === null ? null : dollarsToCredits(limitDollars), + billingInterval: resolveBillingInterval(orgSubscription), }, }) } diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index a8b23088fa1..9482c76ac9b 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -8,6 +8,7 @@ import { userStats, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -82,7 +83,7 @@ export const GET = withRouteHandler( } const userRole = memberEntry[0].role - const hasAdminAccess = ['owner', 'admin'].includes(userRole) + const hasAdminAccess = isOrgAdminRole(userRole) // Get organization members const query = db @@ -234,7 +235,7 @@ export const POST = withRouteHandler( ) } - if (!['owner', 'admin'].includes(memberEntry[0].role)) { + if (!isOrgAdminRole(memberEntry[0].role)) { return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts index f3380ef2b74..c17f862602b 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts @@ -82,27 +82,23 @@ export const POST = withRouteHandler( // check and inserts are atomic against concurrent adds or scope changes. await acquirePermissionGroupOrgLock(tx, organizationId) - // Re-read the group's scope under the lock: a concurrent scope change may - // have flipped all-vs-specific (and cleared its workspaces) since the - // pre-transaction load, so the conflict check must use one consistent - // snapshot of appliesToAllWorkspaces + workspaces. + // Re-read the group under the lock: a concurrent scope change may have + // changed its workspaces since the pre-transaction load, so the conflict + // check uses one consistent snapshot. const lockedGroup = await loadGroupInOrganization(id, organizationId, tx) if (!lockedGroup) { throw new Error('GROUP_NOT_FOUND') } - // Bulk add is all-or-nothing for conflicts: if any selected user would be - // governed by two groups on the same workspace (all-vs-all, or specific - // groups sharing a workspace), add nobody and surface the conflict so the - // admin can fix the selection. Members already in this group are no-ops. - const groupWorkspaceIds = lockedGroup.appliesToAllWorkspaces - ? [] - : (await getGroupWorkspaces(id, tx)).map((ws) => ws.id) + // Bulk add is all-or-nothing for conflicts: if any selected user is + // already an explicit member of another group sharing one of this group's + // workspaces, add nobody and surface the conflict so the admin can fix the + // selection. Members already in this group are no-ops. + const groupWorkspaceIds = (await getGroupWorkspaces(id, tx)).map((ws) => ws.id) const conflicts = await findScopeConflicts( { organizationId, excludeGroupId: id, - appliesToAllWorkspaces: lockedGroup.appliesToAllWorkspaces, workspaceIds: groupWorkspaceIds, candidateUserIds: targetUserIds, }, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts index 4d7c961805d..b93de559ed1 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts @@ -4,7 +4,7 @@ import { permissionGroupMember, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' +import { and, count, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { addPermissionGroupMemberContract } from '@/lib/api/contracts/permission-groups' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' @@ -13,9 +13,12 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types' import { isOrganizationMember } from '@/lib/workspaces/permissions/utils' import { + type AllMembersConflict, acquirePermissionGroupOrgLock, authorizeOrgAccessControl, + findAllMembersWorkspaceConflict, findScopeConflicts, + formatAllMembersConflictError, formatScopeConflictError, getGroupWorkspaces, loadGroupInOrganization, @@ -102,10 +105,9 @@ export const POST = withRouteHandler( // the user in two groups that overlap on a workspace. await acquirePermissionGroupOrgLock(tx, organizationId) - // Re-read the group's scope under the lock: a concurrent scope change may - // have flipped all-vs-specific (and cleared its workspaces) since the - // pre-transaction load, so the conflict check must use one consistent - // snapshot of appliesToAllWorkspaces + workspaces. + // Re-read the group under the lock: a concurrent scope change may have + // changed its workspaces since the pre-transaction load, so the conflict + // check uses one consistent snapshot. const lockedGroup = await loadGroupInOrganization(id, organizationId, tx) if (!lockedGroup) { throw new Error('GROUP_NOT_FOUND') @@ -127,16 +129,13 @@ export const POST = withRouteHandler( } // A user may belong to multiple groups, but only one may govern any given - // workspace. Reject when this group's scope would overlap a group the user - // is already in (all-vs-all, or specific groups sharing a workspace). - const groupWorkspaceIds = lockedGroup.appliesToAllWorkspaces - ? [] - : (await getGroupWorkspaces(id, tx)).map((ws) => ws.id) + // workspace. Reject when the user is already an explicit member of another + // group that shares one of this group's workspaces. + const groupWorkspaceIds = (await getGroupWorkspaces(id, tx)).map((ws) => ws.id) const conflicts = await findScopeConflicts( { organizationId, excludeGroupId: id, - appliesToAllWorkspaces: lockedGroup.appliesToAllWorkspaces, workspaceIds: groupWorkspaceIds, candidateUserIds: [userId], }, @@ -238,6 +237,10 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'memberId is required' }, { status: 400 }) } + // Populated inside the transaction when an all-members scope conflict is + // detected, so the catch can format the 409 after the rollback. + let allMembersConflict: AllMembersConflict | null = null + try { const denied = await authorizeOrgAccessControl(session.user.id, organizationId) if (denied) return denied @@ -247,27 +250,58 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) } - const [memberToRemove] = await db - .select({ - id: permissionGroupMember.id, - userId: permissionGroupMember.userId, - email: user.email, - }) - .from(permissionGroupMember) - .innerJoin(user, eq(permissionGroupMember.userId, user.id)) - .where( - and( - eq(permissionGroupMember.id, memberId), - eq(permissionGroupMember.permissionGroupId, id) + const memberToRemove = await db.transaction(async (tx) => { + // Serialize permission-group writes for this org so the last-member check + // and the delete commit atomically: removing the last member turns a + // workspace group into an all-members group, which is unique per workspace. + await acquirePermissionGroupOrgLock(tx, organizationId) + + const lockedGroup = await loadGroupInOrganization(id, organizationId, tx) + if (!lockedGroup) { + throw new Error('GROUP_NOT_FOUND') + } + + const [member] = await tx + .select({ + id: permissionGroupMember.id, + userId: permissionGroupMember.userId, + email: user.email, + }) + .from(permissionGroupMember) + .innerJoin(user, eq(permissionGroupMember.userId, user.id)) + .where( + and( + eq(permissionGroupMember.id, memberId), + eq(permissionGroupMember.permissionGroupId, id) + ) ) - ) - .limit(1) + .limit(1) - if (!memberToRemove) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } + if (!member) { + throw new Error('MEMBER_NOT_FOUND') + } + + if (!lockedGroup.isDefault && !lockedGroup.appliesToAllWorkspaces) { + const [memberCountRow] = await tx + .select({ value: count() }) + .from(permissionGroupMember) + .where(eq(permissionGroupMember.permissionGroupId, id)) + if ((memberCountRow?.value ?? 0) <= 1) { + const workspaceIds = (await getGroupWorkspaces(id, tx)).map((ws) => ws.id) + const conflict = await findAllMembersWorkspaceConflict( + { organizationId, excludeGroupId: id, workspaceIds }, + tx + ) + if (conflict) { + allMembersConflict = conflict + throw new Error('ALL_MEMBERS_CONFLICT') + } + } + } - await db.delete(permissionGroupMember).where(eq(permissionGroupMember.id, memberId)) + await tx.delete(permissionGroupMember).where(eq(permissionGroupMember.id, memberId)) + return member + }) logger.info('Removed member from permission group', { permissionGroupId: id, @@ -297,6 +331,28 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true }) } catch (error) { + if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } + if (error instanceof Error && error.message === 'MEMBER_NOT_FOUND') { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + } + if ( + error instanceof Error && + error.message === 'ALL_MEMBERS_CONFLICT' && + allMembersConflict + ) { + return NextResponse.json( + { error: formatAllMembersConflictError(allMembersConflict) }, + { status: 409 } + ) + } + if (getPostgresErrorCode(error) === '55P03') { + return NextResponse.json( + { error: 'This group is being updated by another request. Please try again.' }, + { status: 503 } + ) + } logger.error('Error removing member from permission group', error) return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 }) } diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts index 2899d27bcf5..bf6fcabd0e7 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts @@ -16,10 +16,13 @@ import { parsePermissionGroupConfig, } from '@/lib/permission-groups/types' import { + type AllMembersConflict, acquirePermissionGroupOrgLock, authorizeOrgAccessControl, + findAllMembersWorkspaceConflict, findScopeConflicts, findWorkspacesNotInOrganization, + formatAllMembersConflictError, formatScopeConflictError, getGroupWorkspaces, loadGroupInOrganization, @@ -69,6 +72,7 @@ export const PUT = withRouteHandler( // Populated inside the transaction when a scope conflict is detected, so the // catch can format the 409 after the rollback. let scopeConflicts: ScopeConflict[] = [] + let allMembersConflict: AllMembersConflict | null = null try { const denied = await authorizeOrgAccessControl(session.user.id, organizationId) @@ -111,16 +115,28 @@ export const PUT = withRouteHandler( ? { ...currentConfig, ...updates.config } : currentConfig + // Demoting the org default with no new scope: it becomes a non-default + // group with no workspaces (inert) until an admin re-scopes it. The client + // sends only `isDefault: false`, so this never forwards a workspace list + // (which a non-default group otherwise requires) against the per-group cap. + const demotingDefaultToInert = + group.isDefault && + updates.isDefault === false && + updates.appliesToAllWorkspaces === undefined && + updates.workspaceIds === undefined + // Resolve the target workspace scope. Setting the group as default forces // all-workspaces; otherwise an explicit `appliesToAllWorkspaces` wins, and // supplying `workspaceIds` alone implies a specific scope. const scopeProvided = + demotingDefaultToInert || updates.appliesToAllWorkspaces !== undefined || updates.workspaceIds !== undefined || updates.isDefault === true - const resolvedAppliesToAll = - updates.isDefault === true + const resolvedAppliesToAll = demotingDefaultToInert + ? false + : updates.isDefault === true ? true : updates.appliesToAllWorkspaces !== undefined ? updates.appliesToAllWorkspaces @@ -136,6 +152,12 @@ export const PUT = withRouteHandler( { status: 400 } ) } + if (!effectiveIsDefault && resolvedAppliesToAll) { + return NextResponse.json( + { error: 'Non-default groups must target specific workspaces' }, + { status: 400 } + ) + } // Resolve and validate explicitly-provided workspaceIds before the // transaction. When the request omits them for a specific-scope group @@ -178,7 +200,7 @@ export const PUT = withRouteHandler( if (!resolvedAppliesToAll) { resolvedWorkspaceIds = providedWorkspaceIds ?? (await getGroupWorkspaces(id, tx)).map((ws) => ws.id) - if (resolvedWorkspaceIds.length === 0) { + if (resolvedWorkspaceIds.length === 0 && !demotingDefaultToInert) { throw new Error('NO_WORKSPACES') } } @@ -191,7 +213,6 @@ export const PUT = withRouteHandler( { organizationId, excludeGroupId: id, - appliesToAllWorkspaces: resolvedAppliesToAll, workspaceIds: resolvedWorkspaceIds, candidateUserIds: members.map((m) => m.userId), }, @@ -201,12 +222,28 @@ export const PUT = withRouteHandler( scopeConflicts = conflicts throw new Error('SCOPE_CONFLICT') } + + // With no explicit members the group governs all members of its + // workspaces; reject when another all-members group already does. + if (!resolvedAppliesToAll && members.length === 0) { + const conflict = await findAllMembersWorkspaceConflict( + { organizationId, excludeGroupId: id, workspaceIds: resolvedWorkspaceIds }, + tx + ) + if (conflict) { + allMembersConflict = conflict + throw new Error('ALL_MEMBERS_CONFLICT') + } + } } if (updates.isDefault === true) { + // Demote the prior default to a non-default group. It must also drop + // the all-workspaces scope (only the default may be org-wide); it ends + // up with no workspaces (inert) until an admin re-scopes it. await tx .update(permissionGroup) - .set({ isDefault: false, updatedAt: now }) + .set({ isDefault: false, appliesToAllWorkspaces: false, updatedAt: now }) .where( and( eq(permissionGroup.organizationId, organizationId), @@ -287,6 +324,16 @@ export const PUT = withRouteHandler( { status: 409 } ) } + if ( + error instanceof Error && + error.message === 'ALL_MEMBERS_CONFLICT' && + allMembersConflict + ) { + return NextResponse.json( + { error: formatAllMembersConflictError(allMembersConflict) }, + { status: 409 } + ) + } if (error instanceof Error && error.message === 'NO_WORKSPACES') { return NextResponse.json( { error: 'Select at least one workspace when the group targets specific workspaces' }, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts index 3e947d06c32..16b9080b6d7 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts @@ -22,8 +22,12 @@ import { parsePermissionGroupConfig, } from '@/lib/permission-groups/types' import { + type AllMembersConflict, + acquirePermissionGroupOrgLock, authorizeOrgAccessControl, + findAllMembersWorkspaceConflict, findWorkspacesNotInOrganization, + formatAllMembersConflictError, getWorkspacesForGroups, } from '@/app/api/organizations/[id]/permission-groups/utils' @@ -94,6 +98,10 @@ export const POST = withRouteHandler( const { id: organizationId } = await context.params + // Populated inside the transaction when an all-members scope conflict is + // detected, so the catch can format the 409 after the rollback. + let allMembersConflict: AllMembersConflict | null = null + try { const denied = await authorizeOrgAccessControl(session.user.id, organizationId) if (denied) return denied @@ -105,17 +113,20 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { name, description, config, isDefault } = parsed.data.body - // Resolve scope the same way the update route does: the default group is - // always organization-wide; otherwise an explicit `appliesToAllWorkspaces` - // wins, and supplying `workspaceIds` alone implies a specific scope (so - // those ids are never silently dropped). - const requestedWorkspaceIds = parsed.data.body.workspaceIds ?? [] - const appliesToAllWorkspaces = isDefault - ? true - : parsed.data.body.appliesToAllWorkspaces !== undefined - ? parsed.data.body.appliesToAllWorkspaces - : requestedWorkspaceIds.length === 0 - const workspaceIds = appliesToAllWorkspaces ? [] : Array.from(new Set(requestedWorkspaceIds)) + // Only the organization default group is org-wide; every other group + // targets specific workspaces (the contract rejects all-workspaces scope + // for non-default groups). + const appliesToAllWorkspaces = isDefault === true + const workspaceIds = appliesToAllWorkspaces + ? [] + : Array.from(new Set(parsed.data.body.workspaceIds ?? [])) + + if (!appliesToAllWorkspaces && workspaceIds.length === 0) { + return NextResponse.json( + { error: 'Select at least one workspace when the group targets specific workspaces' }, + { status: 400 } + ) + } if (!appliesToAllWorkspaces) { const invalid = await findWorkspacesNotInOrganization(workspaceIds, organizationId) @@ -162,10 +173,28 @@ export const POST = withRouteHandler( } await db.transaction(async (tx) => { + await acquirePermissionGroupOrgLock(tx, organizationId) + + // A new non-default group has no members, so it governs all members of + // its workspaces; reject when another all-members group already does. + if (!appliesToAllWorkspaces) { + const conflict = await findAllMembersWorkspaceConflict( + { organizationId, excludeGroupId: newGroup.id, workspaceIds }, + tx + ) + if (conflict) { + allMembersConflict = conflict + throw new Error('ALL_MEMBERS_CONFLICT') + } + } + if (isDefault) { + // Demote the prior default to a non-default group. It must also drop + // the all-workspaces scope (only the default may be org-wide); it ends + // up with no workspaces (inert) until an admin re-scopes it. await tx .update(permissionGroup) - .set({ isDefault: false, updatedAt: now }) + .set({ isDefault: false, appliesToAllWorkspaces: false, updatedAt: now }) .where( and( eq(permissionGroup.organizationId, organizationId), @@ -215,6 +244,22 @@ export const POST = withRouteHandler( return NextResponse.json({ permissionGroup: { ...newGroup, workspaceIds } }, { status: 201 }) } catch (error) { + if ( + error instanceof Error && + error.message === 'ALL_MEMBERS_CONFLICT' && + allMembersConflict + ) { + return NextResponse.json( + { error: formatAllMembersConflictError(allMembersConflict) }, + { status: 409 } + ) + } + if (getPostgresErrorCode(error) === '55P03') { + return NextResponse.json( + { error: 'This organization is being updated by another request. Please try again.' }, + { status: 503 } + ) + } if (getPostgresErrorCode(error) === '23505') { const constraint = getPostgresConstraintName(error) if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationName) { diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts index a0740b40481..43f46c0beea 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts @@ -3,22 +3,31 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan, mockConflictRows } = - vi.hoisted(() => ({ - mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(), - mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), - mockConflictRows: { - value: [] as Array<{ - userId: string - userName: string | null - userEmail: string | null - otherGroupId: string - otherGroupName: string - otherAppliesToAll: boolean - otherWorkspaceId: string | null - }>, - }, - })) +const { + mockIsOrganizationAdminOrOwner, + mockIsOrganizationOnEnterprisePlan, + mockConflictRows, + mockAllMembersRows, +} = vi.hoisted(() => ({ + mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(), + mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), + mockConflictRows: { + value: [] as Array<{ + userId: string + userName: string | null + userEmail: string | null + otherGroupId: string + otherGroupName: string + }>, + }, + mockAllMembersRows: { + value: [] as Array<{ + conflictingGroupId: string + conflictingGroupName: string + workspaceName: string + }>, + }, +})) vi.mock('@/lib/billing', () => ({ isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, @@ -35,8 +44,13 @@ vi.mock('@sim/db', () => ({ chain.from = vi.fn(() => chain) chain.innerJoin = vi.fn(() => chain) chain.leftJoin = vi.fn(() => chain) - // findScopeConflicts awaits the builder directly after `where`. - chain.where = vi.fn(() => Promise.resolve(mockConflictRows.value)) + chain.where = vi.fn(() => chain) + chain.orderBy = vi.fn(() => chain) + // findAllMembersWorkspaceConflict ends in `.limit(1)`; findScopeConflicts + // awaits the builder directly after `.where()`. + chain.limit = vi.fn(() => Promise.resolve(mockAllMembersRows.value)) + chain.then = (onFulfilled: (rows: unknown) => unknown) => + Promise.resolve(mockConflictRows.value).then(onFulfilled) return chain }), }, @@ -56,9 +70,14 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn(), inArray: vi.fn(), ne: vi.fn(), + sql: vi.fn(), })) -import { authorizeOrgAccessControl, findScopeConflicts } from './utils' +import { + authorizeOrgAccessControl, + findAllMembersWorkspaceConflict, + findScopeConflicts, +} from './utils' describe('authorizeOrgAccessControl', () => { beforeEach(() => { @@ -109,91 +128,102 @@ describe('findScopeConflicts', () => { const baseParams = { organizationId: 'org-1', excludeGroupId: 'group-1', + workspaceIds: ['ws-1'], candidateUserIds: ['user-1'], } - /** Build a conflict-query row with sensible defaults. */ - const row = (overrides: { otherAppliesToAll: boolean; otherWorkspaceId: string | null }) => ({ - userId: 'user-1', + const conflictRow = (userId: string, otherGroupName = 'Marketing') => ({ + userId, userName: 'User One', - userEmail: 'user-1@example.com', + userEmail: `${userId}@example.com`, otherGroupId: 'group-2', - otherGroupName: 'Marketing', - ...overrides, + otherGroupName, }) it('returns no conflicts when there are no candidate users', async () => { - mockConflictRows.value = [row({ otherAppliesToAll: true, otherWorkspaceId: null })] + mockConflictRows.value = [conflictRow('user-1')] - const conflicts = await findScopeConflicts({ - ...baseParams, - appliesToAllWorkspaces: true, - workspaceIds: [], - candidateUserIds: [], - }) + const conflicts = await findScopeConflicts({ ...baseParams, candidateUserIds: [] }) expect(conflicts).toEqual([]) }) - it('flags an all-workspaces target when the user is in another all-workspaces group', async () => { - mockConflictRows.value = [row({ otherAppliesToAll: true, otherWorkspaceId: null })] + it('returns no conflicts when there are no target workspaces', async () => { + mockConflictRows.value = [conflictRow('user-1')] - const conflicts = await findScopeConflicts({ - ...baseParams, - appliesToAllWorkspaces: true, - workspaceIds: [], - }) + const conflicts = await findScopeConflicts({ ...baseParams, workspaceIds: [] }) - expect(conflicts.map((c) => c.userId)).toEqual(['user-1']) - expect(conflicts[0].conflictingGroupName).toBe('Marketing') + expect(conflicts).toEqual([]) }) - it('allows an all-workspaces target when the user is only in a specific group', async () => { - mockConflictRows.value = [row({ otherAppliesToAll: false, otherWorkspaceId: 'ws-1' })] + it('flags a candidate already in another group that shares a workspace', async () => { + mockConflictRows.value = [conflictRow('user-1')] - const conflicts = await findScopeConflicts({ - ...baseParams, - appliesToAllWorkspaces: true, - workspaceIds: [], - }) + const conflicts = await findScopeConflicts(baseParams) - expect(conflicts).toEqual([]) + expect(conflicts.map((c) => c.userId)).toEqual(['user-1']) + expect(conflicts[0].conflictingGroupName).toBe('Marketing') }) - it('flags a specific target that shares a workspace with another specific group', async () => { - mockConflictRows.value = [row({ otherAppliesToAll: false, otherWorkspaceId: 'ws-1' })] + it('returns at most one conflict per user', async () => { + mockConflictRows.value = [conflictRow('user-1', 'Marketing'), conflictRow('user-1', 'Sales')] - const conflicts = await findScopeConflicts({ - ...baseParams, - appliesToAllWorkspaces: false, - workspaceIds: ['ws-1', 'ws-2'], - }) + const conflicts = await findScopeConflicts(baseParams) - expect(conflicts.map((c) => c.userId)).toEqual(['user-1']) + expect(conflicts).toHaveLength(1) expect(conflicts[0].conflictingGroupName).toBe('Marketing') }) - it('allows a specific target whose workspaces are disjoint from the user other specific group', async () => { - mockConflictRows.value = [row({ otherAppliesToAll: false, otherWorkspaceId: 'ws-3' })] + it('returns no conflicts when the query finds no overlapping memberships', async () => { + mockConflictRows.value = [] - const conflicts = await findScopeConflicts({ - ...baseParams, - appliesToAllWorkspaces: false, - workspaceIds: ['ws-1', 'ws-2'], - }) + const conflicts = await findScopeConflicts(baseParams) expect(conflicts).toEqual([]) }) +}) - it('allows a specific target when the user is only in an all-workspaces group', async () => { - mockConflictRows.value = [row({ otherAppliesToAll: true, otherWorkspaceId: null })] +describe('findAllMembersWorkspaceConflict', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAllMembersRows.value = [] + }) + + const baseParams = { + organizationId: 'org-1', + excludeGroupId: 'group-1', + workspaceIds: ['ws-1', 'ws-2'], + } + + it('returns null when there are no target workspaces', async () => { + mockAllMembersRows.value = [ + { conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' }, + ] + + const conflict = await findAllMembersWorkspaceConflict({ ...baseParams, workspaceIds: [] }) - const conflicts = await findScopeConflicts({ - ...baseParams, - appliesToAllWorkspaces: false, - workspaceIds: ['ws-1'], + expect(conflict).toBeNull() + }) + + it('returns the conflicting all-members group sharing a workspace', async () => { + mockAllMembersRows.value = [ + { conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' }, + ] + + const conflict = await findAllMembersWorkspaceConflict(baseParams) + + expect(conflict).toEqual({ + conflictingGroupId: 'group-2', + conflictingGroupName: 'Marketing', + workspaceName: 'Acme', }) + }) - expect(conflicts).toEqual([]) + it('returns null when no other all-members group targets the workspaces', async () => { + mockAllMembersRows.value = [] + + const conflict = await findAllMembersWorkspaceConflict(baseParams) + + expect(conflict).toBeNull() }) }) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts index dfc0332e335..1121ab161bc 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts @@ -159,16 +159,6 @@ export async function listOrganizationWorkspaces(organizationId: string): Promis .orderBy(asc(workspace.name)) } -/** - * Given a candidate group scope, return which of `candidateUserIds` would - * violate the one-effective-group-per-workspace rule through their OTHER - * memberships in the organization: - * - an all-workspaces target conflicts with another all-workspaces membership; - * - a specific target conflicts with another specific membership that shares a - * workspace. - * All-vs-specific never conflicts (specific overrides all for its workspaces). - * The candidate group itself (`excludeGroupId`) is ignored. - */ /** A member whose other group membership would conflict with a candidate scope. */ export interface ScopeConflict { userId: string @@ -179,19 +169,24 @@ export interface ScopeConflict { conflictingGroupName: string } +/** + * Which of `candidateUserIds` would be governed by two groups on the same + * workspace: each is already an explicit member of another non-default group + * that shares one of `workspaceIds`. The candidate group (`excludeGroupId`) and + * the org default group are ignored — the default never governs through + * membership. Returns at most one conflict per user. + */ export async function findScopeConflicts( params: { organizationId: string excludeGroupId: string - appliesToAllWorkspaces: boolean workspaceIds: string[] candidateUserIds: string[] }, executor: DbOrTx = db ): Promise { - const { organizationId, excludeGroupId, appliesToAllWorkspaces, workspaceIds, candidateUserIds } = - params - if (candidateUserIds.length === 0) return [] + const { organizationId, excludeGroupId, workspaceIds, candidateUserIds } = params + if (candidateUserIds.length === 0 || workspaceIds.length === 0) return [] const rows = await executor .select({ @@ -200,12 +195,10 @@ export async function findScopeConflicts( userEmail: user.email, otherGroupId: permissionGroup.id, otherGroupName: permissionGroup.name, - otherAppliesToAll: permissionGroup.appliesToAllWorkspaces, - otherWorkspaceId: permissionGroupWorkspace.workspaceId, }) .from(permissionGroupMember) .innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id)) - .leftJoin( + .innerJoin( permissionGroupWorkspace, eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id) ) @@ -214,34 +207,78 @@ export async function findScopeConflicts( and( eq(permissionGroupMember.organizationId, organizationId), inArray(permissionGroupMember.userId, candidateUserIds), - ne(permissionGroupMember.permissionGroupId, excludeGroupId) + ne(permissionGroupMember.permissionGroupId, excludeGroupId), + eq(permissionGroup.isDefault, false), + inArray(permissionGroupWorkspace.workspaceId, workspaceIds) ) ) - const targetWorkspaceSet = new Set(workspaceIds) const conflictByUser = new Map() - for (const row of rows) { if (conflictByUser.has(row.userId)) continue - const isConflict = appliesToAllWorkspaces - ? row.otherAppliesToAll - : !row.otherAppliesToAll && - row.otherWorkspaceId !== null && - targetWorkspaceSet.has(row.otherWorkspaceId) - if (isConflict) { - conflictByUser.set(row.userId, { - userId: row.userId, - userName: row.userName, - userEmail: row.userEmail, - conflictingGroupId: row.otherGroupId, - conflictingGroupName: row.otherGroupName, - }) - } + conflictByUser.set(row.userId, { + userId: row.userId, + userName: row.userName, + userEmail: row.userEmail, + conflictingGroupId: row.otherGroupId, + conflictingGroupName: row.otherGroupName, + }) } - return Array.from(conflictByUser.values()) } +/** An existing all-members group that already governs everyone in a shared workspace. */ +export interface AllMembersConflict { + conflictingGroupId: string + conflictingGroupName: string + workspaceName: string +} + +/** + * For a group that will govern *all members* of `workspaceIds` (a non-default + * group with no explicit members), return the first other non-default + * all-members group already targeting one of those workspaces, or `null`. Two + * all-members groups on one workspace would both claim everyone there, so this + * is rejected at assignment time. The candidate group (`excludeGroupId`) is + * ignored. + */ +export async function findAllMembersWorkspaceConflict( + params: { organizationId: string; excludeGroupId: string; workspaceIds: string[] }, + executor: DbOrTx = db +): Promise { + const { organizationId, excludeGroupId, workspaceIds } = params + if (workspaceIds.length === 0) return null + + const [row] = await executor + .select({ + conflictingGroupId: permissionGroup.id, + conflictingGroupName: permissionGroup.name, + workspaceName: workspace.name, + }) + .from(permissionGroup) + .innerJoin( + permissionGroupWorkspace, + eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id) + ) + .innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id)) + .where( + and( + eq(permissionGroup.organizationId, organizationId), + eq(permissionGroup.isDefault, false), + ne(permissionGroup.id, excludeGroupId), + inArray(permissionGroupWorkspace.workspaceId, workspaceIds), + sql`not exists ( + select 1 from ${permissionGroupMember} + where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id} + )` + ) + ) + .orderBy(asc(workspace.name)) + .limit(1) + + return row ?? null +} + /** * Human-readable 409 message for a scope/membership conflict, naming the member * and the group they already belong to that overlaps the requested workspaces. @@ -258,3 +295,11 @@ export function formatScopeConflictError(conflicts: ScopeConflict[]): string { const others = conflicts.length - 1 return `${who} and ${others} other member${others === 1 ? '' : 's'} already belong to groups that target these workspaces (e.g. "${first.conflictingGroupName}"). Resolve their group memberships first.` } + +/** + * Human-readable 409 message when another group already governs everyone in a + * workspace this group would also apply to all members of. + */ +export function formatAllMembersConflictError(conflict: AllMembersConflict): string { + return `The group "${conflict.conflictingGroupName}" already applies to everyone in "${conflict.workspaceName}". Two groups can't both govern all members of the same workspace — add members to one of them, or remove that workspace from one group first.` +} diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index 8ccdcfe6227..fe8dc0edbc2 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -8,6 +8,7 @@ import { workspace, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { organizationParamsSchema } from '@/lib/api/contracts/organization' @@ -117,16 +118,25 @@ export const GET = withRouteHandler( permissionsByUser.set(row.userId, list) } - const members = memberRows.map((row) => ({ - memberId: row.memberId, - userId: row.userId, - role: row.role, - createdAt: row.createdAt, - name: row.userName, - email: row.userEmail, - image: row.userImage, - workspaces: permissionsByUser.get(row.userId) ?? [], - })) + const members = memberRows.map((row) => { + const isOrgAdmin = isOrgAdminRole(row.role) + return { + memberId: row.memberId, + userId: row.userId, + role: row.role, + createdAt: row.createdAt, + name: row.userName, + email: row.userEmail, + image: row.userImage, + workspaces: isOrgAdmin + ? orgWorkspaces.map((ws) => ({ + workspaceId: ws.id, + workspaceName: ws.name, + permission: 'admin' as const, + })) + : (permissionsByUser.get(row.userId) ?? []), + } + }) const externalPermissionRows = orgWorkspaceIds.length > 0 diff --git a/apps/sim/app/api/organizations/[id]/route.ts b/apps/sim/app/api/organizations/[id]/route.ts index d44cd97e1c0..ba074c3ab5f 100644 --- a/apps/sim/app/api/organizations/[id]/route.ts +++ b/apps/sim/app/api/organizations/[id]/route.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateOrganizationContract } from '@/lib/api/contracts/organization' @@ -73,7 +74,7 @@ export const GET = withRouteHandler( } const userRole = memberEntry[0].role - const hasAdminAccess = ['owner', 'admin'].includes(userRole) + const hasAdminAccess = isOrgAdminRole(userRole) const response: OrganizationDetailsResponse = { success: true, @@ -148,7 +149,7 @@ export const PUT = withRouteHandler( ) } - if (!['owner', 'admin'].includes(memberEntry[0].role)) { + if (!isOrgAdminRole(memberEntry[0].role)) { return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index 5a2aaabb2d2..ba1fece2f3d 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, subscription as subscriptionTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { getErrorMessage } from '@sim/utils/errors' import { and, eq, inArray, or } from 'drizzle-orm' import type { NextRequest } from 'next/server' @@ -113,7 +114,7 @@ export const POST = withRouteHandler(async (request: Request) => { .limit(1) const existingAdminMembership = - existingOrgMembership.length > 0 && ['owner', 'admin'].includes(existingOrgMembership[0].role) + existingOrgMembership.length > 0 && isOrgAdminRole(existingOrgMembership[0].role) ? existingOrgMembership[0] : null diff --git a/apps/sim/app/api/proxy/tts/stream/route.ts b/apps/sim/app/api/proxy/tts/stream/route.ts index 39d561522a9..c9a91675a5e 100644 --- a/apps/sim/app/api/proxy/tts/stream/route.ts +++ b/apps/sim/app/api/proxy/tts/stream/route.ts @@ -43,7 +43,10 @@ async function validateChatAuth(request: NextRequest, chatId: string): Promise ({ + mockCheckAccess: vi.fn(), + mockLoadEnrichmentDetail: vi.fn(), +})) + +vi.mock('@/lib/table/rows/executions', () => ({ + loadEnrichmentDetail: mockLoadEnrichmentDetail, +})) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + } +}) + +import { GET } from '@/app/api/table/[tableId]/rows/[rowId]/enrichment/[groupId]/route' + +function buildTable(): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { columns: [] }, + metadata: null, + rowCount: 1, + maxRows: 1_000_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + } +} + +function makeRequest(tableId = 'tbl_1', rowId = 'row_1', groupId = 'grp_1') { + const req = new NextRequest( + `http://localhost:3000/api/table/${tableId}/rows/${rowId}/enrichment/${groupId}` + ) + return GET(req, { params: Promise.resolve({ tableId, rowId, groupId }) }) +} + +const detail: EnrichmentRunDetail = { + startedAt: '2026-06-18T00:00:00.000Z', + completedAt: '2026-06-18T00:00:01.000Z', + durationMs: 1000, + totalCost: 0.05, + matchedProvider: 'hunter', + aborted: false, + providers: [ + { + id: 'hunter', + label: 'Hunter', + toolId: 'hunter_find_email', + status: 'matched', + cost: 0.05, + durationMs: 1000, + error: null, + }, + ], +} + +describe('GET /api/table/[tableId]/rows/[rowId]/enrichment/[groupId]', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + }) + + it('returns the enrichment detail', async () => { + mockLoadEnrichmentDetail.mockResolvedValue(detail) + const res = await makeRequest() + expect(res.status).toBe(200) + const json = await res.json() + expect(json).toEqual({ success: true, data: { detail } }) + expect(mockLoadEnrichmentDetail).toHaveBeenCalledWith( + expect.anything(), + 'tbl_1', + 'row_1', + 'grp_1' + ) + }) + + it('returns null when there is no recorded run', async () => { + mockLoadEnrichmentDetail.mockResolvedValue(null) + const res = await makeRequest() + expect(res.status).toBe(200) + const json = await res.json() + expect(json).toEqual({ success: true, data: { detail: null } }) + }) + + it('401s when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const res = await makeRequest() + expect(res.status).toBe(401) + expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled() + }) + + it('denies when access check fails', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + const res = await makeRequest() + expect(res.status).toBe(403) + expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts new file mode 100644 index 00000000000..34a045f7677 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts @@ -0,0 +1,51 @@ +import { db } from '@sim/db' +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { getEnrichmentDetailContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadEnrichmentDetail } from '@/lib/table/rows/executions' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('EnrichmentDetailAPI') + +interface RouteParams { + params: Promise<{ tableId: string; rowId: string; groupId: string }> +} + +/** + * GET /api/table/[tableId]/rows/[rowId]/enrichment/[groupId] + * + * Returns the enrichment cascade breakdown (provider outcomes, cost, timing) + * for one enrichment cell. Read on demand by the enrichment details panel — + * this data is deliberately kept off the hot grid read. Returns `null` for + * cells with no recorded run or runs that predate the feature. + */ +export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(getEnrichmentDetailContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId, rowId, groupId } = parsed.data.params + + const result = await checkAccess(tableId, authResult.userId, 'read') + if (!result.ok) return accessError(result, requestId, tableId) + + const detail = await loadEnrichmentDetail(db, tableId, rowId, groupId) + + logger.info(`[${requestId}] Loaded enrichment detail`, { + tableId, + rowId, + groupId, + hasDetail: detail !== null, + }) + + return NextResponse.json({ success: true, data: { detail } }) +}) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 33986a2964e..7424258ad0e 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' import { @@ -170,7 +171,7 @@ async function checkTableWriteAccess(tableId: string, userId: string): Promise { const data = (entry as JSZip.JSZipObject & { _data?: { uncompressedSize?: number } })._data const size = data?.uncompressedSize return typeof size === 'number' && Number.isFinite(size) ? size : undefined } +type InflateResult = { ok: true; buffer: Buffer } | { ok: false; reason: 'entry' | 'total' } + +/** + * Inflate a single zip entry through a streaming counting sink, tearing the + * stream down the moment cumulative output would exceed the per-entry cap or the + * remaining total budget. The declared uncompressed size in the ZIP header is + * attacker-controlled and is NOT trusted here: a forged-small or absent size + * cannot cause the full (potentially gigabyte-scale) entry to be materialized in + * memory, because enforcement happens on the actual inflated bytes as they + * arrive. Peak memory is bounded by the cap plus one DEFLATE chunk. + */ +const inflateEntryWithinCaps = ( + entry: JSZip.JSZipObject, + remainingTotalBudget: number +): Promise => + new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let size = 0 + let settled = false + const stream = entry.nodeStream() as Readable + + const settle = (result: InflateResult) => { + if (settled) return + settled = true + stream.destroy() + resolve(result) + } + + stream.on('data', (chunk: Buffer) => { + size += chunk.length + if (size > MAX_DECOMPRESS_ENTRY_BYTES) { + settle({ ok: false, reason: 'entry' }) + return + } + if (size > remainingTotalBudget) { + settle({ ok: false, reason: 'total' }) + return + } + chunks.push(chunk) + }) + stream.on('end', () => settle({ ok: true, buffer: Buffer.concat(chunks, size) })) + stream.on('error', (error) => { + if (settled) return + settled = true + stream.destroy() + reject(error) + }) + }) + /** True when a zip entry's unix mode marks it as a symlink (never extracted). */ const isSymlinkEntry = (entry: JSZip.JSZipObject): boolean => { const mode = (entry as JSZip.JSZipObject & { unixPermissions?: number | null }).unixPermissions @@ -770,8 +826,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { safeEntries.push({ entry, segments }) } - // Reject standard zip bombs up front using the declared uncompressed sizes, - // before materializing any entry into memory. let declaredTotal = 0 for (const { entry } of safeEntries) { const declaredSize = readEntryUncompressedSize(entry) @@ -781,19 +835,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (declaredTotal > MAX_DECOMPRESS_TOTAL_BYTES) return totalTooLargeResponse() } - // Read and validate every safe entry before writing anything, so a cap - // breach never leaves partially-extracted files behind in the workspace. const pending: Array<{ segments: string[]; buffer: Buffer }> = [] let totalBytes = 0 for (const { entry, segments } of safeEntries) { - const buffer = await entry.async('nodebuffer') - // Enforce the per-entry cap on the materialized size too, covering - // entries that omit a declared uncompressed size. - if (buffer.length > MAX_DECOMPRESS_ENTRY_BYTES) return entryTooLargeResponse(entry.name) - totalBytes += buffer.length - if (totalBytes > MAX_DECOMPRESS_TOTAL_BYTES) return totalTooLargeResponse() - - pending.push({ segments, buffer }) + const result = await inflateEntryWithinCaps( + entry, + MAX_DECOMPRESS_TOTAL_BYTES - totalBytes + ) + if (!result.ok) { + return result.reason === 'entry' + ? entryTooLargeResponse(entry.name) + : totalTooLargeResponse() + } + totalBytes += result.buffer.length + pending.push({ segments, buffer: result.buffer }) } if (pending.length === 0) { diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts index 0f088980489..62fe6405bfd 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/workflow-authz' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { adminV1DeployWorkflowContract, adminV1UndeployWorkflowContract, diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts index a72a86dfb0c..2a92ca70b6b 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts @@ -15,7 +15,7 @@ import { db } from '@sim/db' import { workflowBlocks, workflowEdges } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/workflow-authz' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { count, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts index 686cbc71211..360a817c5b6 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/workflow-authz' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { adminV1ActivateWorkflowVersionContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts index 6744ab589b4..832121896b7 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/workflow-authz' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { adminV1ListWorkflowVersionsContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index dd20072dc09..bd6a2185dd5 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -1,8 +1,8 @@ import { db } from '@sim/db' -import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, sql } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1ListLogsContract } from '@/lib/api/contracts/v1/logs' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' @@ -13,8 +13,8 @@ import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, - checkWorkspaceScope, createRateLimitResponse, + validateWorkspaceAccess, } from '@/app/api/v1/middleware' const logger = createLogger('V1LogsAPI') @@ -68,8 +68,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const params = parsed.data.query - const scopeError = await checkWorkspaceScope(rateLimit, params.workspaceId) - if (scopeError) return scopeError + const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (accessError) return accessError logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, { userId, @@ -121,14 +121,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) const logs = await baseQuery .where(conditions) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 94cacfd27f8..51d69070f32 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { type NextRequest, NextResponse } from 'next/server' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import type { SubscriptionPlan } from '@/lib/core/rate-limiter' @@ -192,9 +193,6 @@ export async function checkWorkspaceScope( return null } -/** Orders workspace permission levels for at-least comparisons. */ -const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const - /** * Validates workspace-scoped API key bounds and the user's workspace permission. * Returns null on success, NextResponse on failure. @@ -203,13 +201,13 @@ export async function validateWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, - level: keyof typeof PERMISSION_RANK = 'read' + level: PermissionType = 'read' ): Promise { const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null || PERMISSION_RANK[permission] < PERMISSION_RANK[level]) { + if (!permissionSatisfies(permission, level)) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } return null diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts index 3dead585727..dd78899e2ac 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts @@ -5,8 +5,9 @@ * workspace admin permission enforcement, optional body handling, and the * mapping of orchestration results to v1 API responses. */ + +import { WorkflowLockedError } from '@sim/platform-authz/workflow' import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' -import { WorkflowLockedError } from '@sim/workflow-authz' import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index 34982534db9..008a45a9820 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { v1DeployWorkflowBodySchema, diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts index c1f085faf02..cdebe9e35af 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts @@ -5,8 +5,9 @@ * resolution (previous version by default, explicit version when provided) * and the mapping of activation results to v1 API responses. */ + +import { WorkflowLockedError } from '@sim/platform-authz/workflow' import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' -import { WorkflowLockedError } from '@sim/workflow-authz' import { NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index c35933d2773..534e7fd89de 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { v1RollbackWorkflowBodySchema, diff --git a/apps/sim/app/api/v1/workflows/[id]/route.ts b/apps/sim/app/api/v1/workflows/[id]/route.ts index 584f82cd75d..653b833e591 100644 --- a/apps/sim/app/api/v1/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { getActiveWorkflowRecord } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1GetWorkflowContract } from '@/lib/api/contracts/v1/workflows' diff --git a/apps/sim/app/api/v1/workflows/utils.ts b/apps/sim/app/api/v1/workflows/utils.ts index 92e321f1538..89186235598 100644 --- a/apps/sim/app/api/v1/workflows/utils.ts +++ b/apps/sim/app/api/v1/workflows/utils.ts @@ -1,4 +1,4 @@ -import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/workflow-authz' +import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { NextResponse } from 'next/server' import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware' diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index ea79ee38f69..7ad630c5ff6 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -6,7 +6,7 @@ import { assertWorkflowMutable, authorizeWorkflowByWorkspacePermission, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 80f34950ed2..8c7fdec2ee8 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -1,14 +1,14 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' +import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId, generateShortId } from '@sim/utils/id' import { assertWorkflowMutable, authorizeWorkflowByWorkspacePermission, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId, generateShortId } from '@sim/utils/id' import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { listWebhooksContract, upsertWebhookContract } from '@/lib/api/contracts/webhooks' @@ -31,6 +31,7 @@ import { findConflictingWebhookPathOwner, syncWebhooksForCredentialSet, } from '@/lib/webhooks/utils.server' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants' const logger = createLogger('WebhooksAPI') @@ -151,12 +152,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ webhooks: [] }, { status: 200 }) } - const workspacePermissionRows = await db - .select({ workspaceId: permissions.entityId }) - .from(permissions) - .where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace'))) - - const workspaceIds = workspacePermissionRows.map((row) => row.workspaceId) + const accessibleRows = await listAccessibleWorkspaceRowsForUser(session.user.id, 'all') + const workspaceIds = accessibleRows.map((row) => row.workspace.id) if (workspaceIds.length === 0) { return NextResponse.json({ webhooks: [] }, { status: 200 }) } diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts index 18d1864daef..387047da880 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { assertWorkflowMutable, authorizeWorkflowByWorkspacePermission, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { workflowAutoLayoutContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts index c8202be91dc..49d555b813d 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { getChatDeploymentStatusContract } from '@/lib/api/contracts/deployments' diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 0355519c16b..75eca4dc779 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,7 +1,7 @@ import { db, workflow } from '@sim/db' import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updatePublicApiContract } from '@/lib/api/contracts/deployments' diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index e7a95618bcd..1b2746f525f 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import type { NextRequest } from 'next/server' import { workflowDeploymentVersionParamSchema } from '@/lib/api/contracts/workflows' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index 7ab119524d8..beba4a3f3ab 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { FolderLockedError } from '@sim/workflow-authz' +import { FolderLockedError } from '@sim/platform-authz/workflow' import { type NextRequest, NextResponse } from 'next/server' import { duplicateWorkflowContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 8f3007b5c4e..13ee516f661 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' import { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId, isValidUuid } from '@sim/utils/id' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { executeWorkflowBodySchema } from '@/lib/api/contracts/workflows' diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 92f32a26f7d..0ca39eb6622 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts index 5e41a225e9e..27529007563 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts @@ -21,7 +21,7 @@ vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, })) -vi.mock('@sim/workflow-authz', () => ({ +vi.mock('@sim/platform-authz/workflow', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, })) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts index 6915a8dcbc1..2be5fed1c37 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { streamWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index 65a8fa96196..b42dcdb9ce4 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -1,6 +1,10 @@ import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + FolderLockedError, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' -import { assertFolderMutable, FolderLockedError, WorkflowLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { restoreWorkflowContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index d42e75b67e1..d2c1f607b2d 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -7,7 +7,7 @@ import { authorizeWorkflowByWorkspacePermission, FolderLockedError, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkflowContract } from '@/lib/api/contracts/workflows' diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 5aa8010084a..fbd33b045b4 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -1,12 +1,12 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { assertWorkflowMutable, authorizeWorkflowByWorkspacePermission, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' +import { toError } from '@sim/utils/errors' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { putWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows' diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index b2fd323324b..d6b0dd3115f 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -2,12 +2,12 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { assertWorkflowMutable, authorizeWorkflowByWorkspacePermission, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { workflowVariablesContract } from '@/lib/api/contracts/workflows' diff --git a/apps/sim/app/api/workflows/middleware.test.ts b/apps/sim/app/api/workflows/middleware.test.ts index 996466426da..202326d2c15 100644 --- a/apps/sim/app/api/workflows/middleware.test.ts +++ b/apps/sim/app/api/workflows/middleware.test.ts @@ -16,7 +16,7 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('@sim/workflow-authz', () => workflowAuthzMock) +vi.mock('@sim/platform-authz/workflow', () => workflowAuthzMock) vi.mock('@/lib/api-key/service', () => ({ authenticateApiKeyFromHeader: vi.fn(), updateApiKeyLastUsed: vi.fn(), diff --git a/apps/sim/app/api/workflows/middleware.ts b/apps/sim/app/api/workflows/middleware.ts index 10fa3017727..08a51fbf598 100644 --- a/apps/sim/app/api/workflows/middleware.ts +++ b/apps/sim/app/api/workflows/middleware.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import type { NextRequest } from 'next/server' import { type ApiKeyAuthResult, diff --git a/apps/sim/app/api/workflows/reorder/route.ts b/apps/sim/app/api/workflows/reorder/route.ts index 5be0f62d3e5..adb1b5416e5 100644 --- a/apps/sim/app/api/workflows/reorder/route.ts +++ b/apps/sim/app/api/workflows/reorder/route.ts @@ -8,7 +8,7 @@ import { FolderLockedError, FolderNotFoundError, WorkflowLockedError, -} from '@sim/workflow-authz' +} from '@sim/platform-authz/workflow' import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { reorderWorkflowsContract } from '@/lib/api/contracts/workflows' diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index 2bfddb39343..261b8bf4b5d 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -88,7 +88,7 @@ describe('Workflows API Route - POST ordering', () => { }) it('rejects creating a workflow inside a locked folder', async () => { - const { FolderLockedError } = await import('@sim/workflow-authz') + const { FolderLockedError } = await import('@sim/platform-authz/workflow') workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValueOnce( new FolderLockedError('Folder is locked') ) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 224cb68417d..05d94a31b1d 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { type NextRequest, NextResponse } from 'next/server' import { createWorkflowContract, workflowListQuerySchema } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index e2a87fdfbbc..f7aad4aba15 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -44,10 +44,9 @@ const WORKSPACE_ENV_LOCK_TIMEOUT_MS = 5_000 * Restricts decrypted workspace env values to administrators. Members (including * read-only) receive the variable names with empty values so editor autocomplete * and conflict detection keep working without leaking secret values. A value is - * revealed when the caller is a credential admin of that key, or — for legacy - * keys predating per-secret ACLs — when they hold workspace `admin` permission. - * Mirrors the per-key edit gating in PUT/DELETE: if you can administer a secret, - * you can read it. + * revealed when the caller is a workspace admin (which includes organization + * admins) or a per-secret credential admin of that key. Mirrors the per-key edit + * gating in PUT/DELETE: if you can administer a secret, you can read it. */ async function maskWorkspaceEnvForViewer({ workspaceDecrypted, @@ -61,7 +60,7 @@ async function maskWorkspaceEnvForViewer({ permission: PermissionType }): Promise> { const workspaceKeys = Object.keys(workspaceDecrypted) - const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({ + const { adminKeys } = await getWorkspaceEnvKeyAdminAccess({ workspaceId, envKeys: workspaceKeys, userId, @@ -69,7 +68,7 @@ async function maskWorkspaceEnvForViewer({ const masked: Record = {} for (const key of workspaceKeys) { - const canViewValue = adminKeys.has(key) || (!knownKeys.has(key) && permission === 'admin') + const canViewValue = permission === 'admin' || adminKeys.has(key) masked[key] = canViewValue ? workspaceDecrypted[key] : '' } return masked @@ -169,7 +168,8 @@ export const PUT = withRouteHandler( envKeys: incomingKeys, userId, }) - const forbiddenExisting = incomingKeys.filter((k) => knownKeys.has(k) && !adminKeys.has(k)) + const isKeyAdmin = (key: string) => permission === 'admin' || adminKeys.has(key) + const forbiddenExisting = incomingKeys.filter((k) => knownKeys.has(k) && !isKeyAdmin(k)) if (forbiddenExisting.length > 0) { logger.warn(`[${requestId}] Workspace env update denied`, { workspaceId, @@ -311,7 +311,8 @@ export const DELETE = withRouteHandler( envKeys: keys, userId, }) - const forbiddenExisting = keys.filter((k) => knownKeys.has(k) && !adminKeys.has(k)) + const isKeyAdmin = (key: string) => permission === 'admin' || adminKeys.has(key) + const forbiddenExisting = keys.filter((k) => knownKeys.has(k) && !isKeyAdmin(k)) if (forbiddenExisting.length > 0) { logger.warn(`[${requestId}] Workspace env delete denied`, { workspaceId, diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts new file mode 100644 index 00000000000..9865aee2651 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts @@ -0,0 +1,171 @@ +/** + * @vitest-environment node + */ +import { auditMock, authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetWorkspaceFile, mockGetShareForResource, mockUpsertFileShare, mockValidateSharing } = + vi.hoisted(() => ({ + mockGetWorkspaceFile: vi.fn(), + mockGetShareForResource: vi.fn(), + mockUpsertFileShare: vi.fn(), + mockValidateSharing: vi.fn(), + })) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, +})) + +vi.mock('@/lib/public-shares/share-manager', () => { + class ShareValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'ShareValidationError' + } + } + return { + getShareForResource: mockGetShareForResource, + upsertFileShare: mockUpsertFileShare, + ShareValidationError, + } +}) + +vi.mock('@/ee/access-control/utils/permission-check', () => { + class PublicFileSharingNotAllowedError extends Error { + constructor() { + super('Public file sharing is not allowed based on your permission group settings') + this.name = 'PublicFileSharingNotAllowedError' + } + } + return { validatePublicFileSharing: mockValidateSharing, PublicFileSharingNotAllowedError } +}) + +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@sim/audit', () => auditMock) + +const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' +const FILE_ID = 'wf_abc' + +import { ShareValidationError } from '@/lib/public-shares/share-manager' +import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route' + +const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) }) + +const putRequest = (body: unknown) => + new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + +const getRequest = () => + new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`) + +const SHARE = { + id: 'sh_1', + token: 'tok_1', + url: 'https://sim.ai/f/tok_1', + isActive: true, + resourceType: 'file' as const, + resourceId: FILE_ID, +} + +describe('share route', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'User One', email: 'u@example.com' }, + }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetWorkspaceFile.mockResolvedValue({ id: FILE_ID, name: 'report.pdf' }) + mockGetShareForResource.mockResolvedValue(SHARE) + mockUpsertFileShare.mockResolvedValue(SHARE) + mockValidateSharing.mockResolvedValue(undefined) // policy allows by default + }) + + describe('GET', () => { + it('returns 401 when unauthenticated', async () => { + authMockFns.mockGetSession.mockResolvedValueOnce(null) + const res = await GET(getRequest(), params()) + expect(res.status).toBe(401) + }) + + it('returns 403 when the caller has no workspace access', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce(null) + const res = await GET(getRequest(), params()) + expect(res.status).toBe(403) + }) + + it('returns the share for a member', async () => { + const res = await GET(getRequest(), params()) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ share: SHARE }) + }) + }) + + describe('PUT', () => { + it('returns 403 for a read-only member', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') + const res = await PUT(putRequest({ isActive: true }), params()) + expect(res.status).toBe(403) + expect(mockUpsertFileShare).not.toHaveBeenCalled() + }) + + it('maps a ShareValidationError to 400, not 500', async () => { + mockUpsertFileShare.mockRejectedValueOnce( + new ShareValidationError('Password is required for password-protected shares') + ) + const res = await PUT(putRequest({ isActive: true, authType: 'password' }), params()) + expect(res.status).toBe(400) + expect((await res.json()).error).toBe('Password is required for password-protected shares') + }) + + it('returns 404 when the file is not in the workspace', async () => { + mockGetWorkspaceFile.mockResolvedValueOnce(null) + const res = await PUT(putRequest({ isActive: true }), params()) + expect(res.status).toBe(404) + expect(mockUpsertFileShare).not.toHaveBeenCalled() + }) + + it('enables the share for a writer', async () => { + const res = await PUT(putRequest({ isActive: true }), params()) + expect(res.status).toBe(200) + expect(mockUpsertFileShare).toHaveBeenCalledWith({ + workspaceId: WS, + fileId: FILE_ID, + userId: 'user-1', + isActive: true, + }) + expect(await res.json()).toEqual({ share: SHARE }) + }) + + it('returns 403 when org access-control disables public sharing (enable)', async () => { + const { PublicFileSharingNotAllowedError } = await import( + '@/ee/access-control/utils/permission-check' + ) + mockValidateSharing.mockRejectedValueOnce(new PublicFileSharingNotAllowedError()) + const res = await PUT(putRequest({ isActive: true }), params()) + expect(res.status).toBe(403) + expect(mockUpsertFileShare).not.toHaveBeenCalled() + }) + + it('allows disabling a share even when policy disallows enabling', async () => { + mockValidateSharing.mockRejectedValue(new Error('should not be called for disable')) + const res = await PUT(putRequest({ isActive: false }), params()) + expect(res.status).toBe(200) + expect(mockValidateSharing).not.toHaveBeenCalled() + expect(mockUpsertFileShare).toHaveBeenCalledWith({ + workspaceId: WS, + fileId: FILE_ID, + userId: 'user-1', + isActive: false, + }) + }) + + it('rejects a missing isActive body', async () => { + const res = await PUT(putRequest({}), params()) + expect(res.status).toBe(400) + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts new file mode 100644 index 00000000000..d056dfa0223 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts @@ -0,0 +1,158 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { getFileShareContract, upsertFileShareContract } from '@/lib/api/contracts/public-shares' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + getShareForResource, + ShareValidationError, + upsertFileShare, +} from '@/lib/public-shares/share-manager' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + PublicFileSharingNotAllowedError, + validatePublicFileSharing, +} from '@/ee/access-control/utils/permission-check' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('WorkspaceFileShareAPI') + +/** + * GET /api/workspaces/[id]/files/[fileId]/share + * Fetch the public share state for a file (requires workspace membership). + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { + const requestId = generateRequestId() + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getFileShareContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission === null) { + logger.warn( + `[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const file = await getWorkspaceFile(workspaceId, fileId) + if (!file) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + const share = await getShareForResource('file', fileId) + return NextResponse.json({ share }) + } catch (error) { + logger.error(`[${requestId}] Error fetching file share:`, error) + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to fetch share') }, + { + status: 500, + } + ) + } + } +) + +/** + * PUT /api/workspaces/[id]/files/[fileId]/share + * Enable or disable the public share for a file (requires write permission). + */ +export const PUT = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { + const requestId = generateRequestId() + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(upsertFileShareContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params + const { isActive, authType, password, allowedEmails, token } = parsed.data.body + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + logger.warn( + `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const file = await getWorkspaceFile(workspaceId, fileId) + if (!file) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + // Enabling a share is gated by the org's access-control policy (both the + // master on/off and the per-auth-type allow-list); disabling is always + // allowed so users can still un-share after the policy is turned on. + if (isActive) { + try { + await validatePublicFileSharing(session.user.id, workspaceId, authType ?? 'public') + } catch (error) { + if (error instanceof PublicFileSharingNotAllowedError) { + logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`) + return NextResponse.json({ error: error.message }, { status: 403 }) + } + throw error + } + } + + const share = await upsertFileShare({ + workspaceId, + fileId, + userId: session.user.id, + isActive, + authType, + password, + allowedEmails, + token, + }) + + logger.info(`[${requestId}] ${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`) + + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, + request, + }) + + return NextResponse.json({ share }) + } catch (error) { + if (error instanceof ShareValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + logger.error(`[${requestId}] Error updating file share:`, error) + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to update share') }, + { + status: 500, + } + ) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 75caa8542e7..b13a8d08b6f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -11,6 +11,7 @@ import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { getSharesForResources } from '@/lib/public-shares/share-manager' import { FileConflictError, listWorkspaceFiles, @@ -68,11 +69,20 @@ export const GET = withRouteHandler( const files = await listWorkspaceFiles(workspaceId, { scope }) + const shares = await getSharesForResources( + 'file', + files.map((file) => file.id) + ) + const filesWithShares = files.map((file) => ({ + ...file, + share: shares.get(file.id) ?? null, + })) + logger.info(`[${requestId}] Listed ${files.length} files for workspace ${workspaceId}`) return NextResponse.json({ success: true, - files, + files: filesWithShares, }) } catch (error) { logger.error(`[${requestId}] Error listing workspace files:`, error) diff --git a/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts b/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts index 2bf216a930f..548a6939c43 100644 --- a/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts @@ -1,11 +1,12 @@ -import { db, dbReplica } from '@sim/db' -import { pausedExecutions, permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' +import { dbReplica } from '@sim/db' +import { pausedExecutions, workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { workspaceMetricsExecutionsQuerySchema } from '@/lib/api/contracts/workspaces' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MetricsExecutionsAPI') @@ -36,18 +37,8 @@ export const GET = withRouteHandler( const segments = qp.segments - const [permission] = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.userId, userId) - ) - ) - .limit(1) - if (!permission) { + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.hasAccess) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } const wfWhere = [eq(workflow.workspaceId, workspaceId)] as any[] diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 8e9bbd5bf32..43a450765b5 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -1,9 +1,10 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema' +import { member, permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { ORG_ADMIN_ROLES } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces' import { parseRequest } from '@/lib/api/server' @@ -112,7 +113,10 @@ export const PATCH = withRouteHandler( const body = parsed.data.body const workspaceRow = await db - .select({ billedAccountUserId: workspace.billedAccountUserId }) + .select({ + billedAccountUserId: workspace.billedAccountUserId, + organizationId: workspace.organizationId, + }) .from(workspace) .where(eq(workspace.id, workspaceId)) .limit(1) @@ -122,6 +126,27 @@ export const PATCH = withRouteHandler( } const billedAccountUserId = workspaceRow[0].billedAccountUserId + const organizationId = workspaceRow[0].organizationId + + if (organizationId) { + const targetUserIds = body.updates.map((update) => update.userId) + const orgAdminTargets = await db + .select({ userId: member.userId }) + .from(member) + .where( + and( + eq(member.organizationId, organizationId), + inArray(member.userId, targetUserIds), + inArray(member.role, [...ORG_ADMIN_ROLES]) + ) + ) + if (orgAdminTargets.length > 0) { + return NextResponse.json( + { error: 'Organization admins are workspace admins and their role cannot be changed' }, + { status: 400 } + ) + } + } const selfUpdate = body.updates.find((update) => update.userId === session.user.id) if (selfUpdate && selfUpdate.permissions !== 'admin') { diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 1e8bbac5b82..1b215792d94 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -14,7 +14,10 @@ const logger = createLogger('WorkspaceByIdAPI') import { db } from '@sim/db' import { permissions, workspace } from '@sim/db/schema' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + getEffectiveWorkspacePermission, + getUserEntityPermissions, +} from '@/lib/workspaces/permissions/utils' export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { @@ -140,28 +143,11 @@ export const PATCH = withRouteHandler( const candidateId = billedAccountUserId - const isOwner = candidateId === existingWorkspace.ownerId - - let hasAdminAccess = isOwner - - if (!hasAdminAccess) { - const adminPermission = await db - .select({ id: permissions.id }) - .from(permissions) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.userId, candidateId), - eq(permissions.permissionType, 'admin') - ) - ) - .limit(1) - - hasAdminAccess = adminPermission.length > 0 - } - - if (!hasAdminAccess) { + const candidatePermission = await getEffectiveWorkspacePermission( + candidateId, + existingWorkspace + ) + if (candidatePermission !== 'admin') { return NextResponse.json( { error: 'Billed account must be a workspace admin' }, { status: 400 } diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.ts b/apps/sim/app/api/workspaces/invitations/batch/route.ts index 02dc504458a..391a1cb8952 100644 --- a/apps/sim/app/api/workspaces/invitations/batch/route.ts +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -61,6 +61,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) const successful: string[] = [] + const added: string[] = [] const failed: BatchInvitationFailure[] = [] const invitations: WorkspaceInvitationResult[] = [] const seenEmails = new Set() @@ -83,7 +84,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { permission: item.permission, request: req, }) - successful.push(invitation.email) + if (invitation.instantAdd) { + // Only report an actual insertion; an `unchanged` outcome means the + // user already had access (rare race) and is a silent no-op. + if (invitation.outcome === 'added') added.push(invitation.email) + } else { + successful.push(invitation.email) + } invitations.push(invitation) } catch (error) { if (error instanceof WorkspaceInvitationError) { @@ -102,6 +109,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: failed.length === 0, successful, + added, failed, invitations, }) diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index c364d8228e4..ddfe8a59213 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -124,6 +124,7 @@ describe('POST /api/workspaces/invitations/batch', () => { workspaceMode: 'grandfathered_shared', billedAccountUserId: 'user-1', }) + permissionsMockFns.mockHasWorkspaceAdminAccess.mockResolvedValue(true) mockValidateInvitationsAllowed.mockResolvedValue(undefined) mockGetWorkspaceInvitePolicy.mockResolvedValue({ allowed: true, @@ -164,7 +165,7 @@ describe('POST /api/workspaces/invitations/batch', () => { organizationId: null, upgradeRequired: true, }) - mockDbResults.value = [[{ permissionType: 'admin' }]] + mockDbResults.value = [] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -195,7 +196,7 @@ describe('POST /api/workspaces/invitations/batch', () => { organizationId: null, upgradeRequired: true, }) - mockDbResults.value = [[{ permissionType: 'admin' }]] + mockDbResults.value = [] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -233,7 +234,7 @@ describe('POST /api/workspaces/invitations/batch', () => { maxSeats: 5, availableSeats: 0, }) - mockDbResults.value = [[{ permissionType: 'admin' }], []] + mockDbResults.value = [[]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -276,10 +277,7 @@ describe('POST /api/workspaces/invitations/batch', () => { role: 'member', memberId: 'member-1', }) - mockDbResults.value = [ - [{ permissionType: 'admin' }], - [{ id: 'existing-user', email: 'new@example.com' }], - ] + mockDbResults.value = [[{ id: 'existing-user', email: 'new@example.com' }]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -313,7 +311,7 @@ describe('POST /api/workspaces/invitations/batch', () => { workspaceMode: 'grandfathered_shared', billedAccountUserId: 'user-1', }) - mockDbResults.value = [[{ permissionType: 'admin' }], []] + mockDbResults.value = [[]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -338,7 +336,7 @@ describe('POST /api/workspaces/invitations/batch', () => { }) it('creates multiple workspace invitations in one batch request', async () => { - mockDbResults.value = [[{ permissionType: 'admin' }], [], []] + mockDbResults.value = [[], []] mockCreatePendingInvitation .mockResolvedValueOnce({ invitationId: 'inv-1', @@ -384,7 +382,7 @@ describe('POST /api/workspaces/invitations/batch', () => { success: false, error: 'mailer unavailable', }) - mockDbResults.value = [[{ permissionType: 'admin' }], []] + mockDbResults.value = [[]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 378b169ad66..101f0dc8fff 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -1,11 +1,9 @@ -import { db } from '@sim/db' -import { permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listInvitationsForWorkspaces } from '@/lib/invitations/core' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' export const dynamic = 'force-dynamic' @@ -18,24 +16,14 @@ export const GET = withRouteHandler(async (req: NextRequest) => { } try { - const userWorkspaces = await db - .select({ id: workspace.id }) - .from(workspace) - .innerJoin( - permissions, - and( - eq(permissions.entityId, workspace.id), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id) - ) - ) - .where(isNull(workspace.archivedAt)) - - if (userWorkspaces.length === 0) { + const accessibleRows = await listAccessibleWorkspaceRowsForUser(session.user.id) + if (accessibleRows.length === 0) { return NextResponse.json({ invitations: [] }) } - const invitations = await listInvitationsForWorkspaces(userWorkspaces.map((w) => w.id)) + const invitations = await listInvitationsForWorkspaces( + accessibleRows.map((row) => row.workspace.id) + ) return NextResponse.json({ invitations }) } catch (error) { logger.error('Error fetching workspace invitations:', error) diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index 8679aea74c5..54889076a58 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -85,16 +85,10 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - if ( - isRemovingWorkspaceOwner && - !isSelf && - session.user.id !== workspaceRow[0].billedAccountUserId - ) { - return NextResponse.json( - { error: 'Only the workspace owner or billing account can remove the workspace owner' }, - { status: 403 } - ) - } + // Removing the workspace owner is allowed for any admin: ownership transfers + // to the billing account in the transaction below. The billing account itself + // stays protected by the guard above (and personal workspaces, where owner == + // billing account, are blocked there). // Prevent removing yourself if you're the last admin if (isSelf && userPermission?.permissionType === 'admin' && !isRemovingWorkspaceOwner) { diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 42513fa1cca..8d35dc7429b 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { permissions, settings, type WorkspaceMode, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, desc, eq, isNull, sql } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { listWorkspacesQuerySchema } from '@/lib/api/contracts' import { createWorkspaceContract } from '@/lib/api/contracts/workspaces' @@ -26,6 +26,7 @@ import { UPGRADE_TO_INVITE_REASON, WORKSPACE_MODE, } from '@/lib/workspaces/policy' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' const logger = createLogger('Workspaces') @@ -62,29 +63,7 @@ export const GET = withRouteHandler(async (request: Request) => { .limit(1) const [userWorkspaces, userSettings] = await Promise.all([ - db - .select({ - workspace: workspace, - permissionType: permissions.permissionType, - }) - .from(permissions) - .innerJoin(workspace, eq(permissions.entityId, workspace.id)) - .where( - scope === 'all' - ? and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')) - : scope === 'archived' - ? and( - eq(permissions.userId, session.user.id), - eq(permissions.entityType, 'workspace'), - sql`${workspace.archivedAt} IS NOT NULL` - ) - : and( - eq(permissions.userId, session.user.id), - eq(permissions.entityType, 'workspace'), - isNull(workspace.archivedAt) - ) - ) - .orderBy(desc(workspace.createdAt)), + listAccessibleWorkspaceRowsForUser(session.user.id, scope), settingsQuery, ]) diff --git a/apps/sim/app/f/[token]/opengraph-image.tsx b/apps/sim/app/f/[token]/opengraph-image.tsx new file mode 100644 index 00000000000..b5f6541ba3c --- /dev/null +++ b/apps/sim/app/f/[token]/opengraph-image.tsx @@ -0,0 +1,37 @@ +import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { createLandingOgImage } from '@/app/(landing)/og-utils' +import { buildProvenance } from '@/app/f/[token]/utils' + +export const dynamic = 'force-dynamic' +export const contentType = 'image/png' +export const size = { + width: 1200, + height: 630, +} + +/** + * Social-preview card for a shared file. Public shares show the file name + + * provenance; protected (password / email / SSO) and unknown shares stay generic + * so the filename never leaks pre-auth. + */ +export default async function Image({ params }: { params: Promise<{ token: string }> }) { + const { token } = await params + const resolved = await resolveActiveShareByToken(token) + + if (!resolved || resolved.share.authType !== 'public') { + return createLandingOgImage({ + eyebrow: 'Shared file', + title: 'Protected file', + subtitle: 'Authentication is required to view this file', + }) + } + + const { file, workspaceName, ownerName } = resolved + const subtitle = buildProvenance(workspaceName, ownerName) || 'Shared via Sim' + + return createLandingOgImage({ + eyebrow: 'Shared file', + title: file.originalName, + subtitle, + }) +} diff --git a/apps/sim/app/f/[token]/page.tsx b/apps/sim/app/f/[token]/page.tsx new file mode 100644 index 00000000000..3d5506b6919 --- /dev/null +++ b/apps/sim/app/f/[token]/page.tsx @@ -0,0 +1,126 @@ +import { cache } from 'react' +import type { Metadata } from 'next' +import { cookies } from 'next/headers' +import { notFound } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { + deploymentAuthCookieName, + isEmailAllowed, + validateAuthToken, +} from '@/lib/core/security/deployment' +import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { PublicFileAuth } from '@/app/f/[token]/public-file-auth' +import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth' +import { PublicFileSSOAuth } from '@/app/f/[token]/public-file-sso-auth' +import { PublicFileView } from '@/app/f/[token]/public-file-view' +import { buildProvenance } from '@/app/f/[token]/utils' +import { getBrandConfig } from '@/ee/whitelabeling' + +export const dynamic = 'force-dynamic' + +/** Deduped per-request so `generateMetadata` and the page share one DB resolve. */ +const resolveShare = cache(resolveActiveShareByToken) + +/** Shared links must never be indexed by search engines. */ +const NOINDEX = { index: false, follow: false } as const + +interface PublicFilePageProps { + params: Promise<{ token: string }> +} + +/** + * Social-preview metadata. Public shares unfurl with the file name + provenance; + * any protected share (password / email / SSO) stays deliberately generic so the + * filename never leaks before the visitor authenticates. Always `noindex`. + */ +export async function generateMetadata({ params }: PublicFilePageProps): Promise { + const { token } = await params + const resolved = await resolveShare(token) + if (!resolved) { + return { robots: NOINDEX } + } + + let title: string + let description: string + if (resolved.share.authType !== 'public') { + title = 'Shared file' + description = 'Authentication is required to view this file.' + } else { + title = resolved.file.originalName + description = + buildProvenance(resolved.workspaceName, resolved.ownerName) || `Shared file · ${title}` + } + + const brand = getBrandConfig() + return { + title, + description, + robots: NOINDEX, + openGraph: { type: 'website', title, description, siteName: brand.name }, + twitter: { card: 'summary_large_image', title, description }, + } +} + +/** The auth-relevant slice of a resolved share row. */ +interface GateShare { + id: string + authType: string + password: string | null + allowedEmails: unknown +} + +/** + * Returns the auth prompt to render when a protected share is not yet authorized, + * or `null` when the visitor may view the file. `password`/`email` use the + * `file_auth_{shareId}` cookie; `sso` uses the global Sim session. + */ +async function renderAuthGate(token: string, share: GateShare) { + if (share.authType === 'public') return null + + if (share.authType === 'sso') { + const session = await getSession() + const allowedEmails = Array.isArray(share.allowedEmails) + ? (share.allowedEmails as string[]) + : [] + const authorized = Boolean( + session?.user?.email && isEmailAllowed(session.user.email, allowedEmails) + ) + return authorized ? null : + } + + const cookieStore = await cookies() + const cookieValue = cookieStore.get(deploymentAuthCookieName('file', share.id))?.value + if (validateAuthToken(cookieValue ?? '', share.id, share.authType, share.password)) return null + + return share.authType === 'email' ? ( + + ) : ( + + ) +} + +export default async function PublicFilePage({ params }: PublicFilePageProps) { + const { token } = await params + + const resolved = await resolveShare(token) + if (!resolved) { + notFound() + } + + const { share, file, workspaceName, ownerName } = resolved + + const gate = await renderAuthGate(token, share) + if (gate) return gate + + return ( + + ) +} diff --git a/apps/sim/app/f/[token]/public-file-auth-shell.tsx b/apps/sim/app/f/[token]/public-file-auth-shell.tsx new file mode 100644 index 00000000000..72ebe0a8ca0 --- /dev/null +++ b/apps/sim/app/f/[token]/public-file-auth-shell.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react' +import AuthBackground from '@/app/(auth)/components/auth-background' +import { SupportFooter } from '@/app/(auth)/components/support-footer' +import Navbar from '@/app/(landing)/components/navbar/navbar' + +interface PublicFileAuthShellProps { + title: string + subtitle: string + children: ReactNode +} + +/** + * Landing-chrome shell shared by the public file-share auth gates (password, + * email OTP, SSO), matching the deployed-chat auth screens. Renders no file + * metadata — the name/provenance are withheld until the visitor authenticates. + */ +export function PublicFileAuthShell({ title, subtitle, children }: PublicFileAuthShellProps) { + return ( + +
+
+ +
+
+
+
+
+

+ {title} +

+

+ {subtitle} +

+
+
{children}
+
+
+
+ +
+
+ ) +} diff --git a/apps/sim/app/f/[token]/public-file-auth.tsx b/apps/sim/app/f/[token]/public-file-auth.tsx new file mode 100644 index 00000000000..fa7d57dd05a --- /dev/null +++ b/apps/sim/app/f/[token]/public-file-auth.tsx @@ -0,0 +1,102 @@ +'use client' + +import { useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' +import { Eye, EyeOff } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { Input, Label, Loader } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' +import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell' +import { usePublicFileAuth } from '@/hooks/queries/public-shares' + +interface PublicFileAuthProps { + token: string +} + +/** + * Password gate for a protected public file share. On success the + * `file_auth_{shareId}` cookie is set and the page re-renders the viewer. + */ +export function PublicFileAuth({ token }: PublicFileAuthProps) { + const router = useRouter() + const authenticate = usePublicFileAuth(token) + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState(null) + + const handleAuthenticate = async () => { + if (!password.trim()) { + setError('Password is required.') + return + } + setError(null) + try { + await authenticate.mutateAsync({ password }) + router.refresh() + } catch (err) { + setError(getErrorMessage(err, 'Invalid password. Please try again.')) + } + } + + return ( + +
{ + e.preventDefault() + handleAuthenticate() + }} + className='space-y-6' + > +
+ +
+ { + setPassword(e.target.value) + setError(null) + }} + className={cn( + 'pr-10', + error && 'border-[var(--text-error)] focus:border-[var(--text-error)]' + )} + /> + +
+ {error ?

{error}

: null} +
+ + +
+
+ ) +} diff --git a/apps/sim/app/f/[token]/public-file-email-auth.tsx b/apps/sim/app/f/[token]/public-file-email-auth.tsx new file mode 100644 index 00000000000..b05b487574c --- /dev/null +++ b/apps/sim/app/f/[token]/public-file-email-auth.tsx @@ -0,0 +1,217 @@ +'use client' + +import { useEffect, useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' +import { useRouter } from 'next/navigation' +import { Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Loader } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { AUTH_SUBMIT_BTN, AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes' +import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell' +import { usePublicFileOtpRequest, usePublicFileOtpVerify } from '@/hooks/queries/public-shares' + +interface PublicFileEmailAuthProps { + token: string +} + +/** + * Email-OTP gate for a protected public file share: collect an allow-listed email, + * send a 6-digit code, verify it. On success the server sets the + * `file_auth_{shareId}` cookie and the page re-renders the viewer. + */ +export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { + const router = useRouter() + const requestOtp = usePublicFileOtpRequest(token) + const verifyOtp = usePublicFileOtpVerify(token) + + const [email, setEmail] = useState('') + const [otp, setOtp] = useState('') + const [sent, setSent] = useState(false) + const [error, setError] = useState(null) + const [countdown, setCountdown] = useState(0) + + useEffect(() => { + if (countdown <= 0) return + const timer = setTimeout(() => setCountdown((c) => c - 1), 1000) + return () => clearTimeout(timer) + }, [countdown]) + + const sendCode = async () => { + if (!quickValidateEmail(email.trim().toLowerCase()).isValid) { + setError('Please enter a valid email address.') + return + } + setError(null) + try { + await requestOtp.mutateAsync({ email: email.trim().toLowerCase() }) + setSent(true) + setOtp('') + } catch (err) { + setError(getErrorMessage(err, 'Failed to send verification code')) + } + } + + const verifyCode = async (code: string) => { + if (code.length !== 6) return + setError(null) + try { + await verifyOtp.mutateAsync({ email: email.trim().toLowerCase(), otp: code }) + router.refresh() + } catch (err) { + setError(getErrorMessage(err, 'Invalid verification code')) + } + } + + const resend = async () => { + setCountdown(30) + try { + await requestOtp.mutateAsync({ email: email.trim().toLowerCase() }) + setOtp('') + setError(null) + } catch (err) { + setCountdown(0) + setError(getErrorMessage(err, 'Failed to resend verification code')) + } + } + + if (!sent) { + return ( + +
{ + e.preventDefault() + sendCode() + }} + className='space-y-6' + > +
+ + { + setEmail(e.target.value) + setError(null) + }} + className={cn(error && 'border-[var(--text-error)] focus:border-[var(--text-error)]')} + /> + {error ?

{error}

: null} +
+ + +
+
+ ) + } + + return ( + +
+

+ Enter the 6-digit code to verify your access. If you don't see it in your inbox, check + your spam folder. +

+ +
+ { + setOtp(value) + setError(null) + if (value.length === 6) verifyCode(value) + }} + disabled={verifyOtp.isPending} + className={cn('gap-2', error && 'otp-error')} + > + + {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} + + +
+ + {error ?

{error}

: null} + + + +
+

+ Didn't receive a code?{' '} + {countdown > 0 ? ( + + Resend in{' '} + {countdown}s + + ) : ( + + )} +

+
+ +
+ +
+
+
+ ) +} diff --git a/apps/sim/app/f/[token]/public-file-sso-auth.tsx b/apps/sim/app/f/[token]/public-file-sso-auth.tsx new file mode 100644 index 00000000000..247975a4b29 --- /dev/null +++ b/apps/sim/app/f/[token]/public-file-sso-auth.tsx @@ -0,0 +1,103 @@ +'use client' + +import { useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' +import { useRouter } from 'next/navigation' +import { Input, Label, Loader } from '@/components/emcn' +import { requestJson } from '@/lib/api/client/request' +import { publicFileSSOContract } from '@/lib/api/contracts/public-shares' +import { cn } from '@/lib/core/utils/cn' +import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' +import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell' + +interface PublicFileSSOAuthProps { + token: string +} + +/** + * SSO gate for a protected public file share: confirm the email is allow-listed, + * then hand off to the global `/sso` flow with this share as the callback. After + * sign-in the page gate authorizes via the Sim session. + */ +export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) { + const router = useRouter() + const [email, setEmail] = useState('') + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const handleAuthenticate = async () => { + if (!quickValidateEmail(email.trim().toLowerCase()).isValid) { + setError('Please enter a valid email address.') + return + } + setError(null) + setIsLoading(true) + try { + const normalizedEmail = email.trim().toLowerCase() + const { eligible } = await requestJson(publicFileSSOContract, { + params: { token }, + body: { email: normalizedEmail }, + }) + if (!eligible) { + setError('Email not authorized for this file.') + setIsLoading(false) + return + } + const callbackUrl = `/f/${token}` + router.push( + `/sso?email=${encodeURIComponent(normalizedEmail)}&callbackUrl=${encodeURIComponent(callbackUrl)}` + ) + } catch (err) { + setError(getErrorMessage(err, 'Email not authorized for this file.')) + setIsLoading(false) + } + } + + return ( + +
{ + e.preventDefault() + handleAuthenticate() + }} + className='space-y-6' + > +
+ + { + setEmail(e.target.value) + setError(null) + }} + className={cn(error && 'border-[var(--text-error)] focus:border-[var(--text-error)]')} + /> + {error ?

{error}

: null} +
+ + +
+
+ ) +} diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx new file mode 100644 index 00000000000..f27b63df65d --- /dev/null +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -0,0 +1,125 @@ +'use client' + +import { useMemo } from 'react' +import Image from 'next/image' +import Link from 'next/link' +import { Chip } from '@/components/emcn' +import { Download } from '@/components/emcn/icons' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { buildProvenance } from '@/app/f/[token]/utils' +import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer' +import { useBrandConfig } from '@/ee/whitelabeling' +import { type FileContentSource, FileContentSourceProvider } from '@/hooks/use-file-content-source' + +interface PublicFileViewProps { + token: string + name: string + type: string + size: number + /** Content version (the file's `updatedAt`, epoch ms) — busts the viewer's caches when the file changes. */ + version: number + workspaceName: string | null + ownerName: string | null +} + +export function PublicFileView({ + token, + name, + type, + size, + version, + workspaceName, + ownerName, +}: PublicFileViewProps) { + const contentUrl = `/api/files/public/${token}/content` + const brand = useBrandConfig() + const provenance = buildProvenance(workspaceName, ownerName) + + // The public viewer reuses the in-app FileViewer; the content source seam swaps + // the auth-gated workspace serve URL for the token-scoped public endpoint, and a + // synthetic record carries the metadata the renderers/query keys need. `key` and + // `updatedAt` fold in the content version so the React Query caches (keyed on the + // storage key + `updatedAt`) refetch when the shared file changes — even when its + // size is unchanged. + const source = useMemo(() => ({ buildUrl: () => contentUrl }), [contentUrl]) + const file = useMemo( + () => ({ + id: token, + workspaceId: token, + name, + key: `${token}@${version}`, + path: contentUrl, + size, + type, + uploadedBy: '', + folderId: null, + uploadedAt: new Date(version), + updatedAt: new Date(version), + }), + [token, name, type, size, version, contentUrl] + ) + + return ( +
+
+
+ {!brand.logoUrl && ( + <> + + Sim + Sim + +
+ + )} +
+ {name} + {provenance ? ( + {provenance} + ) : null} +
+
+ { + const anchor = document.createElement('a') + anchor.href = contentUrl + anchor.download = name + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + }} + > + Download + +
+ +
+ + + +
+
+ ) +} diff --git a/apps/sim/app/f/[token]/utils.ts b/apps/sim/app/f/[token]/utils.ts new file mode 100644 index 00000000000..0dcf8983ca5 --- /dev/null +++ b/apps/sim/app/f/[token]/utils.ts @@ -0,0 +1,9 @@ +/** + * Provenance label for a shared file (`"{workspace} · Shared by {owner}"`), shared + * by the page metadata, the OG card, and the in-page viewer so the three never + * drift. Returns an empty string when neither is known; callers apply their own + * fallback. + */ +export function buildProvenance(workspaceName: string | null, ownerName: string | null): string { + return [workspaceName, ownerName ? `Shared by ${ownerName}` : null].filter(Boolean).join(' · ') +} diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 82e6f107b77..4ab0bddef79 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -78,26 +78,36 @@ export default function RootLayout({ children }: { children: React.ReactNode }) // window yields a width >= MIN instead of a sub-minimum sliver. var defaultSidebarWidth = 248; try { - var stored = localStorage.getItem('sidebar-state'); - if (stored) { - var parsed = JSON.parse(stored); - var state = parsed && parsed.state; - var isCollapsed = state && state.isCollapsed; - - if (isCollapsed) { - document.documentElement.style.setProperty('--sidebar-width', '51px'); - document.documentElement.setAttribute('data-sidebar-collapsed', ''); - } else { - var width = state && state.sidebarWidth; - var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3); - var finalWidth = - typeof width === 'number' && isFinite(width) - ? Math.min(Math.max(width, 248), maxSidebarWidth) - : defaultSidebarWidth; - document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px'); - } + // Collapse comes from the cookie (independent of localStorage + // parsing); the persisted width is read defensively below. Match the + // value strictly so 'sidebar_collapsed=10' isn't read as collapsed. + var cookieMatch = document.cookie.match(/(?:^|;\s*)sidebar_collapsed=([^;]*)/); + var hasCookie = cookieMatch !== null; + var collapsed = cookieMatch !== null && cookieMatch[1] === '1'; + + var state = null; + try { + var stored = localStorage.getItem('sidebar-state'); + state = stored ? JSON.parse(stored).state : null; + } catch (e) {} + + // One-time migration: seed the cookie from the legacy localStorage + // flag for users who collapsed before the cookie existed. + if (!hasCookie && state && typeof state.isCollapsed === 'boolean') { + collapsed = state.isCollapsed; + document.cookie = 'sidebar_collapsed=' + (collapsed ? '1' : '0') + '; path=/; max-age=31536000; samesite=lax'; + } + + if (collapsed) { + document.documentElement.style.setProperty('--sidebar-width', '51px'); } else { - document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px'); + var width = state && state.sidebarWidth; + var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3); + var finalWidth = + typeof width === 'number' && isFinite(width) + ? Math.min(Math.max(width, 248), maxSidebarWidth) + : defaultSidebarWidth; + document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px'); } } catch (e) { document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px'); diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx index 6177c1bf8a1..a9067b55f1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { Avatar, AvatarFallback, Chip, ChipDropdown } from '@/components/emcn' +import { credentialRoleLockReason, RoleLockTooltip } from '@/components/permissions' import { cn } from '@/lib/core/utils/cn' import { getUserColor } from '@/lib/workspaces/colors' import { @@ -32,7 +33,9 @@ export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMe const removeMember = useRemoveWorkspaceCredentialMember() const activeMembers = members.filter((member) => member.status === 'active') - const adminMemberCount = activeMembers.filter((member) => member.role === 'admin').length + const explicitAdminCount = activeMembers.filter( + (member) => member.role === 'admin' && member.roleSource !== 'workspace-admin' + ).length const handleChangeMemberRole = async (userId: string, role: WorkspaceCredentialRole) => { const current = activeMembers.find((member) => member.userId === userId) @@ -57,8 +60,13 @@ export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMe {membersLoading ? null : (
{activeMembers.map((member) => { - const roleLocked = member.role === 'admin' && adminMemberCount <= 1 - const roleDisabled = !isAdmin || roleLocked + const lockReason = credentialRoleLockReason(member.roleSource) + const roleLocked = + member.role === 'admin' && + member.roleSource !== 'workspace-admin' && + explicitAdminCount <= 1 + const roleDisabled = !isAdmin || roleLocked || lockReason !== null + const removeDisabled = roleLocked || lockReason !== null return (
- - handleChangeMemberRole(member.userId, role as WorkspaceCredentialRole) - } - /> + + + handleChangeMemberRole(member.userId, role as WorkspaceCredentialRole) + } + /> + {isAdmin && ( handleRemoveMember(member.userId)} - disabled={roleLocked} + disabled={removeDisabled} flush className='justify-self-end' > diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index b544c525cae..f03a8cdcdf2 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -371,6 +371,14 @@ interface BreadcrumbLocationPopoverProps { veilBoundaryRef: React.RefObject } +/** + * Grace period before a hover-out dismisses the path popover. Covers the gap + * the pointer crosses between the trigger and the popover content (and brief + * jitter at their edges); re-entering either within this window cancels the + * close. Standard hover-intent close delay — not tied to any navigation timing. + */ +const POPOVER_CLOSE_DELAY_MS = 120 + function BreadcrumbLocationPopover({ icon: Icon, breadcrumbs, @@ -381,22 +389,44 @@ function BreadcrumbLocationPopover({ const closeTimeoutRef = useRef | null>(null) const rootBreadcrumb = breadcrumbs[0] - const openPopover = () => { + const cancelScheduledClose = () => { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current) closeTimeoutRef.current = null } + } + + /** + * Hover-intent open. Driven only by pointer-/keyboard-enter — never by + * pointer movement. This is what makes the popover dismiss cleanly on a + * click-to-navigate: a stationary click fires no enter event, so once + * {@link navigateAndClose} sets `open` false nothing re-opens it before the + * route swaps. (A move-driven open would re-fire under the resting cursor and + * flash the popover/veil back in mid-navigation.) + */ + const openPopover = () => { + cancelScheduledClose() setOpen(true) } const scheduleClose = () => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current) - } + cancelScheduledClose() closeTimeoutRef.current = setTimeout(() => { setOpen(false) closeTimeoutRef.current = null - }, 120) + }, POPOVER_CLOSE_DELAY_MS) + } + + /** + * Closes the popover up front, then runs the crumb's handler. Closing first + * lets the veil fade and the popover play its exit animation instead of + * snapping away when navigation unmounts the header. + */ + const navigateAndClose = (onClick?: () => void) => { + if (!onClick) return + cancelScheduledClose() + setOpen(false) + onClick() } useEffect(() => { @@ -413,15 +443,11 @@ function BreadcrumbLocationPopover({
+ )} + {!isMermaid && + (editor.isEditable ? ( + // Editable: a language picker. Read-only: a static label — selecting a language calls + // updateAttributes, which would mutate a doc that must not change. + + + + + + {LANGUAGE_OPTIONS.map((option) => ( + + updateAttributes({ language: option.value === PLAIN ? null : option.value }) + } + > + {option.label} + + ))} + + + ) : ( + + {label} + + ))} + {!isMermaid && editor.isEditable && ( + + )} + + +
+         as='code' />
+      
+ {showDiagram && ( + // Clicking the diagram selects the whole node (same selection ring as an image/code block) + // instead of dropping a caret inside — preventDefault stops ProseMirror placing the caret, + // which would otherwise flip to source. Editing is an explicit Show source / blur action. +
{ + event.preventDefault() + const pos = typeof getPos === 'function' ? getPos() : null + if (typeof pos === 'number') editor.commands.setNodeSelection(pos) + }} + > + +
+ )} + + ) +} + +function codeBlockText(node: JSONContent): string { + return (node.content ?? []).map((child) => child.text ?? '').join('') +} + +/** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */ +function fenceFor(text: string): string { + const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length)) + return '`'.repeat(Math.max(3, longestRun + 1)) +} + +/** + * Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code + * block that itself contains a ``` line round-trips instead of shattering. Shared by the test + * (plain) and live ({@link CodeBlockWithLanguage}) paths. + */ +export const MarkdownCodeBlock = CodeBlock.extend({ + renderMarkdown: (node: JSONContent) => { + const language = typeof node.attrs?.language === 'string' ? node.attrs.language : '' + const text = codeBlockText(node) + const fence = fenceFor(text) + return `${fence}${language}\n${text}\n${fence}` + }, +}) + +/** + * Code block with hover-revealed controls (language picker, line-wrap toggle, copy). The + * `language` attribute drives {@link CodeBlockHighlight}'s Prism highlighting and serializes to + * the ```lang fence on save; wrap is a view-only preference. + */ +export const CodeBlockWithLanguage = MarkdownCodeBlock.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeBlockView) + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts new file mode 100644 index 00000000000..6b74e26da37 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { buildDecorations, changeTouchesCodeBlock } from './code-highlight' +import { createMarkdownContentExtensions } from './extensions' + +let editor: Editor | null = null + +/** Position just inside the first code block in the current editor doc. */ +function codeBlockPos(ed: Editor): number { + let pos = -1 + ed.state.doc.descendants((node, p) => { + if (pos === -1 && node.type.name === 'codeBlock') pos = p + return pos === -1 + }) + if (pos === -1) throw new Error('no code block') + return pos +} + +function decorationClassesFor(markdown: string): string[] { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + const decorations = buildDecorations(editor.state.doc).find() + editor.destroy() + editor = null + return decorations.map( + (decoration) => + (decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class + ) +} + +afterEach(() => { + editor?.destroy() + editor = null +}) + +describe('code block syntax highlighting', () => { + it('emits Prism token decorations for a known language', () => { + const classes = decorationClassesFor('```js\nconst x = 1\n```') + expect(classes.length).toBeGreaterThan(0) + expect(classes.every((c) => c.startsWith('token'))).toBe(true) + expect(classes.some((c) => c.includes('keyword'))).toBe(true) + }) + + it('does not decorate plain prose', () => { + expect(decorationClassesFor('just some text')).toHaveLength(0) + }) + + it('does not decorate an unregistered language', () => { + expect(decorationClassesFor('```unregistered-lang\n+++ foo\n```')).toHaveLength(0) + }) +}) + +describe('changeTouchesCodeBlock (incremental re-tokenization gate)', () => { + function mount(markdown: string): Editor { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + return editor + } + + it('is false when an edit lands only in prose (decorations are mapped, not rebuilt)', () => { + const ed = mount('intro text\n\n```js\nconst x = 1\n```') + const tr = ed.state.tr.insertText('Z', 1) // inside the leading paragraph + expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(false) + }) + + it('is true when an edit lands inside a code block (forces a re-tokenize)', () => { + const ed = mount('intro\n\n```js\nconst x = 1\n```') + const tr = ed.state.tr.insertText('y', codeBlockPos(ed) + 1) + expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(true) + }) + + it('is true when the code block language changes via setNodeMarkup', () => { + const ed = mount('```js\nconst x = 1\n```') + const pos = codeBlockPos(ed) + const tr = ed.state.tr.setNodeMarkup(pos, undefined, { language: 'python' }) + expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts new file mode 100644 index 00000000000..5609f56922b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts @@ -0,0 +1,133 @@ +import { Extension } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import Prism, { type Token, type TokenStream } from 'prismjs' +import 'prismjs/components/prism-bash' +import 'prismjs/components/prism-css' +import 'prismjs/components/prism-markup' +import 'prismjs/components/prism-javascript' +import 'prismjs/components/prism-typescript' +import 'prismjs/components/prism-yaml' +import 'prismjs/components/prism-sql' +import 'prismjs/components/prism-python' +import 'prismjs/components/prism-json' +import 'prismjs/components/prism-c' +import 'prismjs/components/prism-cpp' +import 'prismjs/components/prism-csharp' +import 'prismjs/components/prism-go' +import 'prismjs/components/prism-java' +import 'prismjs/components/prism-markup-templating' +import 'prismjs/components/prism-php' +import 'prismjs/components/prism-ruby' +import 'prismjs/components/prism-rust' +import { detectLanguage } from './detect-language' + +const HIGHLIGHT_PLUGIN_KEY = new PluginKey('codeBlockHighlight') + +function tokenClasses(token: Token): string { + const classes = ['token', token.type] + if (token.alias) classes.push(...(Array.isArray(token.alias) ? token.alias : [token.alias])) + return classes.join(' ') +} + +/** + * Walks Prism's token tree, emitting one inline decoration per token over its text range. + * Nested tokens stack (ProseMirror nests overlapping inline decorations), reproducing the + * `.token`-class structure Prism would render as HTML. + */ +function collectTokenDecorations( + stream: TokenStream, + base: number, + offset: { value: number }, + decorations: Decoration[], + limit: number +) { + const tokens = Array.isArray(stream) ? stream : [stream] + for (const token of tokens) { + if (typeof token === 'string') { + offset.value += token.length + continue + } + const start = offset.value + collectTokenDecorations(token.content, base, offset, decorations, limit) + const from = base + start + const to = Math.min(base + offset.value, limit) + if (to > from) decorations.push(Decoration.inline(from, to, { class: tokenClasses(token) })) + } +} + +export function buildDecorations(doc: ProseMirrorNode): DecorationSet { + const decorations: Decoration[] = [] + doc.descendants((node, pos) => { + if (node.type.name !== 'codeBlock') return + const language = (node.attrs.language as string | null) ?? detectLanguage(node.textContent) + const grammar = language ? Prism.languages[language] : undefined + if (!grammar) return + // Defensive: a malformed grammar or a token/position mismatch must never throw here — a throw + // in the decorations plugin blanks the whole editor. The `limit` clamps any over-long token. + try { + const base = pos + 1 + collectTokenDecorations( + Prism.tokenize(node.textContent, grammar), + base, + { value: 0 }, + decorations, + base + node.content.size + ) + } catch {} + }) + return DecorationSet.create(doc, decorations) +} + +/** + * Whether the transaction's changed ranges intersect any code block in the new doc — including + * a `setNodeMarkup` language change (whose step range covers the node). When false, the cheap + * path just maps existing decorations instead of re-tokenizing. + */ +export function changeTouchesCodeBlock(tr: Transaction, doc: ProseMirrorNode): boolean { + let touches = false + for (const map of tr.mapping.maps) { + map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + if (touches) return + const from = Math.max(0, Math.min(newStart, doc.content.size)) + const to = Math.max(from, Math.min(newEnd, doc.content.size)) + doc.nodesBetween(from, to, (node) => { + if (node.type.name === 'codeBlock') touches = true + return !touches + }) + }) + } + return touches +} + +/** + * Syntax-highlights fenced code blocks with Prism, emitting the same `.token` classes the + * rest of the app uses so the `code-editor-theme` styles (light + dark) apply unchanged. + * Re-tokenizes only when a change actually touches a code block (typing in prose just maps + * the existing decorations), keeping the cost off the common keystroke path. + */ +export const CodeBlockHighlight = Extension.create({ + name: 'codeBlockHighlight', + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: HIGHLIGHT_PLUGIN_KEY, + state: { + init: (_, { doc }) => buildDecorations(doc), + apply: (tr, current) => { + if (tr.steps.length === 0) return current + if (!changeTouchesCodeBlock(tr, tr.doc)) return current.map(tr.mapping, tr.doc) + return buildDecorations(tr.doc) + }, + }, + props: { + decorations(state) { + return HIGHLIGHT_PLUGIN_KEY.getState(state) + }, + }, + }), + ] + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts new file mode 100644 index 00000000000..d3f830e2ee8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts @@ -0,0 +1,21 @@ +/** + * @vitest-environment jsdom + * + * Guards against drift between the code-block language picker and the Prism grammars actually + * registered by CodeBlockHighlight: every selectable language must have a registered grammar, or it + * would silently fall back to no highlighting. + */ +import Prism from 'prismjs' +import { describe, expect, it } from 'vitest' +import { LANGUAGE_OPTIONS } from './code-block' +// Importing the highlighter registers all the prism-* grammars as a side effect. +import './code-highlight' + +describe('code-block languages', () => { + it('every selectable language has a registered Prism grammar', () => { + for (const { value } of LANGUAGE_OPTIONS) { + if (value === 'plain') continue + expect(Prism.languages[value], `no Prism grammar registered for "${value}"`).toBeDefined() + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.test.ts new file mode 100644 index 00000000000..a5c9194a7f8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { detectLanguage } from './detect-language' + +describe('detectLanguage', () => { + it('returns null for empty or unrecognizable content', () => { + expect(detectLanguage('')).toBeNull() + expect(detectLanguage(' \n ')).toBeNull() + expect(detectLanguage('just some prose words here')).toBeNull() + }) + + it('detects common languages from content shape', () => { + expect(detectLanguage('{\n "a": 1,\n "b": [2, 3]\n}')).toBe('json') + expect(detectLanguage('const x = 1\nfunction go() {}')).toBe('javascript') + expect(detectLanguage('interface Foo { name: string }')).toBe('typescript') + expect(detectLanguage('def main():\n print("hi")')).toBe('python') + expect(detectLanguage('SELECT id FROM users WHERE id = 1')).toBe('sql') + expect(detectLanguage('#!/bin/bash\necho hello')).toBe('bash') + expect(detectLanguage('
hi
')).toBe('markup') + expect(detectLanguage('.btn { color: red; padding: 4px }')).toBe('css') + }) + + it('does not misclassify a JS object as JSON', () => { + expect(detectLanguage('const x = { a: 1 }')).toBe('javascript') + }) + + it('detects Go, Rust, Java', () => { + expect(detectLanguage('package main\n\nfunc main() {\n\tfmt.Println("hi")\n}')).toBe('go') + expect(detectLanguage('type User struct {\n\tName string\n}')).toBe('go') + expect(detectLanguage('fn main() {\n let mut x = 1;\n println!("{}", x);\n}')).toBe('rust') + expect(detectLanguage('public class Box {\n private int n;\n}')).toBe('java') + }) + + it('does not misread generics as HTML markup', () => { + expect(detectLanguage('public class Box { private List items; }')).toBe('java') + expect(detectLanguage('let v: Vec = Vec::new();\nfn f() {}')).toBe('rust') + expect(detectLanguage('func Map[T any](s []T) {}\npackage x')).toBe('go') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.ts new file mode 100644 index 00000000000..d391ed13d29 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.ts @@ -0,0 +1,63 @@ +/** + * Heuristic language detection for a fenced code block that has no explicit ` ```lang ` tag. + * Used only to drive syntax highlighting + the picker label — the detected value is NEVER + * written back to the markdown, so opening a file never mutates it. Restricted to the grammars + * {@link CodeBlockHighlight} actually registers with Prism; returns `null` when unsure. + */ +const DETECTORS: ReadonlyArray<{ language: string; test: RegExp }> = [ + // Real HTML: a closing tag, an opening tag with an attribute, or a doctype/comment. Deliberately + // NOT a bare `` so generics (`List`, `Vec`) aren't misread as markup. + { language: 'markup', test: /<\/[a-z][\w-]*\s*>|<[a-z][\w-]*\s+[\w:-]+=||console\.\w+|\brequire\(|\bexport\s+(default|const)\b/, + }, + { language: 'css', test: /[.#]?[\w-]+\s*\{[^}]*[\w-]+\s*:[^};]+;?[^}]*\}/ }, + { language: 'yaml', test: /^[\w-]+:\s+\S/m }, +] + +function looksLikeJson(sample: string): boolean { + const trimmed = sample.trim() + if (!/^[[{]/.test(trimmed)) return false + try { + JSON.parse(trimmed) + return true + } catch { + return false + } +} + +export function detectLanguage(code: string): string | null { + const sample = code.slice(0, 2000) + if (!sample.trim()) return null + if (looksLikeJson(sample)) return 'json' + for (const { language, test } of DETECTORS) { + if (test.test(sample)) return language + } + return null +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-signal.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-signal.test.ts new file mode 100644 index 00000000000..870907a9a38 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-signal.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment jsdom + * + * The rich editor uses TipTap's initial-content model: opening a file loads its markdown as the + * editor's initial `content`, which must NOT emit an update — so a freshly opened file is never + * marked dirty (no spurious autosave / "unsaved changes"). Only a genuine edit emits, which is what + * flips the dirty/autosave state on. These two cases guard exactly that contract. + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from './extensions' + +let editor: Editor | null = null +afterEach(() => { + editor?.destroy() + editor = null +}) + +function mount(content: string, onUpdate: () => void): Editor { + return new Editor({ + extensions: createMarkdownContentExtensions(), + content, + contentType: 'markdown', + onUpdate, + }) +} + +describe('rich markdown editor — dirty signal', () => { + it('opening a file emits no update (never dirty on open), including markdown that normalizes', () => { + // A trailing newline and `_emphasis_` both normalize on serialization; opening must still be clean. + let updates = 0 + editor = mount('# Title\n\nsome _emphasis_ here\n', () => { + updates++ + }) + expect(updates).toBe(0) + expect(editor.isEmpty).toBe(false) + }) + + it('opening an empty file emits no update and is editable', () => { + let updates = 0 + editor = mount('', () => { + updates++ + }) + expect(updates).toBe(0) + }) + + it('a genuine edit emits an update (marks dirty → triggers autosave)', () => { + let updates = 0 + editor = mount('hello', () => { + updates++ + }) + editor.commands.insertContent(' world') + expect(updates).toBeGreaterThan(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts new file mode 100644 index 00000000000..2610daac7b4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -0,0 +1,116 @@ +import type { Extensions, JSONContent, MarkdownRendererHelpers } from '@tiptap/core' +import { Code } from '@tiptap/extension-code' +import { TaskItem, TaskList } from '@tiptap/extension-list' +import Placeholder from '@tiptap/extension-placeholder' +import { + renderTableToMarkdown, + Table, + TableCell, + TableHeader, + TableRow, +} from '@tiptap/extension-table' +import { Markdown } from '@tiptap/markdown' +import StarterKit from '@tiptap/starter-kit' +import { CodeBlockWithLanguage, MarkdownCodeBlock } from './code-block' +import { CodeBlockHighlight } from './code-highlight' +import { MarkdownImage, ResizableImage } from './image' +import { RichMarkdownKeymap } from './keymap' +import { MarkdownLinkInputRule } from './link-input-rule' +import { MarkdownPaste } from './markdown-paste' +import { SlashCommand } from './slash-command/slash-command' + +/** + * Inline code that can combine with bold/italic/strike (GFM permits `**`x`**`, `~~`x`~~`). + * The stock Code mark sets `excludes: '_'`, which blocks every other mark from coexisting and + * makes the bubble-menu toggles silently no-op over a code selection. + */ +const InlineCode = Code.extend({ excludes: '' }) + +/** + * Table that escapes interior `|` characters when serializing cells. The upstream serializer + * joins cells with `|` without escaping, so a cell containing a literal pipe silently splits + * into phantom columns on round-trip (data loss). Escaping must happen on the `table` node — + * `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly. Only + * `|` is escaped — `renderChildren` already escapes backslashes, so escaping them again would + * double-escape and break round-trip idempotency (CodeQL's "missing backslash escape" is a false + * positive here; covered by the table round-trip tests). + * + * The upstream serializer also wraps the table in its own leading/trailing blank lines; left in, + * the block joiner adds another, so an interior table churns its surrounding whitespace to + * `\n\n\n` on the first edit. Trimming the table's own output lets the joiner own the single + * blank-line separator — without touching blank lines inside fenced code (those live in the code + * node's text, not here). + */ +const PipeSafeTable = Table.extend({ + renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) => + renderTableToMarkdown(node, { + ...h, + renderChildren: (nodes, separator) => + h.renderChildren(nodes, separator).replace(/\|/g, '\\|'), + }) + .replace(/^\n+/, '') + .replace(/\n+$/, ''), +}) + +interface MarkdownEditorExtensionOptions { + placeholder: string +} + +interface ContentExtensionOptions { + /** Use the React node views (code-block language picker, image resize). Off for headless tests. */ + nodeViews?: boolean +} + +/** + * The schema + serialization extensions: the nodes/marks the document can contain and the + * Markdown ⇄ ProseMirror conversion. `StarterKit` provides core nodes/marks and the + * Markdown-style input rules (`# `, `- `, `**bold**`, …); `TaskList`/`TaskItem` add + * `- [ ]` checklists; `TableKit` adds GFM tables; `Markdown` serializes back to markdown. + * + * The code block is the standalone `CodeBlock` so the live editor can swap in a node view; + * the schema and markdown output are identical either way. + */ +export function createMarkdownContentExtensions({ + nodeViews = false, +}: ContentExtensionOptions = {}): Extensions { + const codeBlock = (nodeViews ? CodeBlockWithLanguage : MarkdownCodeBlock).configure({ + HTMLAttributes: { class: 'code-editor-theme' }, + }) + return [ + StarterKit.configure({ + link: { openOnClick: false }, + underline: false, + codeBlock: false, + code: false, + }), + InlineCode, + codeBlock, + (nodeViews ? ResizableImage : MarkdownImage).configure({ allowBase64: true }), + TaskList, + TaskItem.configure({ nested: true }), + PipeSafeTable.configure({ resizable: true }), + TableRow, + TableHeader, + TableCell, + MarkdownLinkInputRule, + Markdown, + ] +} + +/** + * The full extension set for the live editor: the content extensions plus the UI-only + * extensions — `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), and + * `Placeholder`. + */ +export function createMarkdownEditorExtensions({ + placeholder, +}: MarkdownEditorExtensionOptions): Extensions { + return [ + ...createMarkdownContentExtensions({ nodeViews: true }), + CodeBlockHighlight, + SlashCommand, + RichMarkdownKeymap, + MarkdownPaste, + Placeholder.configure({ placeholder }), + ] +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts new file mode 100644 index 00000000000..45a0cb92ae6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from './extensions' +import { findHeadingPos, slugifyHeading } from './heading-anchors' + +let editor: Editor | null = null +afterEach(() => { + editor?.destroy() + editor = null +}) + +/** A ProseMirror doc parsed from markdown, for the position-resolution tests. */ +function docOf(markdown: string) { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + return editor.state.doc +} + +describe('slugifyHeading', () => { + it('lowercases, drops punctuation, and hyphenates whitespace (GitHub-style)', () => { + expect(slugifyHeading('Getting Started')).toBe('getting-started') + expect(slugifyHeading('API Reference!')).toBe('api-reference') + expect(slugifyHeading(' Spaced Out ')).toBe('spaced-out') + expect(slugifyHeading('Node.js & Bun')).toBe('nodejs-bun') + }) + + it('returns an empty string for punctuation-only text', () => { + expect(slugifyHeading('!!!')).toBe('') + expect(slugifyHeading('')).toBe('') + }) +}) + +describe('findHeadingPos', () => { + it('resolves a fragment slug to its heading position', () => { + const doc = docOf('# Intro\n\ntext\n\n## Getting Started\n\nmore') + expect(findHeadingPos(doc, 'intro')).toBeGreaterThanOrEqual(0) + expect(findHeadingPos(doc, 'getting-started')).toBeGreaterThan(findHeadingPos(doc, 'intro')) + }) + + it('disambiguates duplicate slugs GitHub-style (foo, foo-1, foo-2)', () => { + const doc = docOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc') + const first = findHeadingPos(doc, 'notes') + const second = findHeadingPos(doc, 'notes-1') + const third = findHeadingPos(doc, 'notes-2') + expect(first).toBeGreaterThanOrEqual(0) + expect(second).toBeGreaterThan(first) + expect(third).toBeGreaterThan(second) + }) + + it('returns -1 when no heading matches', () => { + const doc = docOf('# Only Heading\n\nbody') + expect(findHeadingPos(doc, 'missing')).toBe(-1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts new file mode 100644 index 00000000000..677964d65e4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts @@ -0,0 +1,36 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' + +/** + * Slugify heading text GitHub-style (lowercase, drop punctuation, collapse whitespace to hyphens) so + * that `[label](#slug)` fragment links — written against how GitHub renders the same markdown — + * resolve to the matching heading. Mirrors what `rehype-slug` produced in the old preview. + */ +export function slugifyHeading(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') +} + +/** + * The document position of the heading a `#slug` fragment link targets, or -1 if none matches. + * Computed on demand (at click time) rather than maintained as per-keystroke decorations. Duplicate + * slugs are disambiguated GitHub-style: `intro`, `intro-1`, `intro-2`, … + */ +export function findHeadingPos(doc: ProseMirrorNode, slug: string): number { + const seen = new Map() + let found = -1 + doc.descendants((node, pos) => { + if (found >= 0) return false + if (node.type.name !== 'heading') return true + const base = slugifyHeading(node.textContent) + if (!base) return true + const n = seen.get(base) ?? 0 + seen.set(base, n + 1) + if ((n === 0 ? base : `${base}-${n}`) === slug) found = pos + return found < 0 + }) + return found +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts new file mode 100644 index 00000000000..766e4c77ef6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import { extractImageFiles } from './image-paste' + +function imageFile(name = 'shot.png'): File { + return new File([''], name, { type: 'image/png' }) +} + +function transfer( + files: File[], + items: Array<{ kind: string; type: string; file: File | null }> = [] +): DataTransfer { + return { + files, + items: items.map((entry) => ({ + kind: entry.kind, + type: entry.type, + getAsFile: () => entry.file, + })), + } as unknown as DataTransfer +} + +describe('extractImageFiles', () => { + it('returns nothing for a null payload or non-image files', () => { + expect(extractImageFiles(null)).toEqual([]) + expect(extractImageFiles(transfer([new File([''], 'a.txt', { type: 'text/plain' })]))).toEqual( + [] + ) + }) + + it('reads images from the files list (drag-drop)', () => { + const file = imageFile() + expect(extractImageFiles(transfer([file]))).toEqual([file]) + }) + + it('falls back to items when files is empty (pasted screenshot)', () => { + const file = imageFile() + const result = extractImageFiles(transfer([], [{ kind: 'file', type: 'image/png', file }])) + expect(result).toEqual([file]) + }) + + it('ignores non-file and non-image items', () => { + const result = extractImageFiles( + transfer( + [], + [ + { kind: 'string', type: 'text/plain', file: null }, + { kind: 'file', type: 'application/pdf', file: new File([''], 'a.pdf') }, + ] + ) + ) + expect(result).toEqual([]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts new file mode 100644 index 00000000000..ff72fededf9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts @@ -0,0 +1,14 @@ +/** + * Extract image `File` objects from a paste/drop payload. Reads `files` first, then falls back to + * `items` — many browsers expose a pasted or copied image (e.g. a screenshot) only through + * `DataTransfer.items` with an empty `files` list, so reading `files` alone misses them. + */ +export function extractImageFiles(transfer: DataTransfer | null): File[] { + if (!transfer) return [] + const fromFiles = Array.from(transfer.files).filter((file) => file.type.startsWith('image/')) + if (fromFiles.length > 0) return fromFiles + return Array.from(transfer.items) + .filter((item) => item.kind === 'file' && item.type.startsWith('image/')) + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts new file mode 100644 index 00000000000..41e2f888408 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts @@ -0,0 +1,27 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import { resolveDisplaySrc } from './image' + +describe('resolveDisplaySrc', () => { + it('rewrites an in-app workspace file path to its serving endpoint (display only)', () => { + expect(resolveDisplaySrc('/workspace/W1/files/F123')).toBe('/api/files/view/F123') + expect(resolveDisplaySrc('/workspace/any-ws-id/files/abc-def')).toBe('/api/files/view/abc-def') + }) + + it('leaves absolute and non-workspace URLs untouched', () => { + expect(resolveDisplaySrc('https://cdn.example.com/a.png')).toBe('https://cdn.example.com/a.png') + expect(resolveDisplaySrc('http://localhost/workspace/W1/files/F1')).toBe( + 'http://localhost/workspace/W1/files/F1' + ) + expect(resolveDisplaySrc('/other/path/files/x')).toBe('/other/path/files/x') + expect(resolveDisplaySrc('relative/image.png')).toBe('relative/image.png') + }) + + it('passes through empty/undefined and unparseable values', () => { + expect(resolveDisplaySrc(undefined)).toBeUndefined() + expect(resolveDisplaySrc('')).toBe('') + expect(resolveDisplaySrc('/workspace/W1/files/')).toBe('/workspace/W1/files/') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx new file mode 100644 index 00000000000..8e76a4244bb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -0,0 +1,283 @@ +import { useEffect, useRef, useState } from 'react' +import type { JSONContent } from '@tiptap/core' +import { Image } from '@tiptap/extension-image' +import type { ReactNodeViewProps } from '@tiptap/react' +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { normalizeLinkHref } from './markdown-fidelity' + +const MIN_WIDTH = 64 + +/** + * A markdown linked image `[![alt](src "t")](href "t2")` — an image wrapped in a link, the canonical + * form of a README badge. `@tiptap/markdown` parses this as a link mark over an image node, but an + * image node can't carry inline marks, so the wrapping link is silently dropped. We instead tokenize + * the whole construct ourselves and hang the link target on the image node's `href` attribute, so it + * round-trips losslessly (and the file stays editable rather than opening read-only). + */ +const LINKED_IMAGE_RE = + /^\[!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/ + +/** Escape a value for safe interpolation into a double-quoted HTML attribute. */ +function escapeAttr(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} + +/** + * Rewrite an in-app workspace file path (`/workspace/{id}/files/{fileId}`) to its serving endpoint + * (`/api/files/view/{fileId}`) for display only — the stored `src` attribute keeps the original path + * so markdown round-trips unchanged. Absolute and non-workspace URLs pass through untouched. + */ +export function resolveDisplaySrc(src: string | undefined): string | undefined { + if (!src) return src + try { + const parsed = new URL(src, 'http://placeholder') + if (parsed.origin !== 'http://placeholder') return src + const [, seg1, , seg3, fileId] = parsed.pathname.split('/') + if (seg1 === 'workspace' && seg3 === 'files' && fileId) return `/api/files/view/${fileId}` + } catch { + // not a parseable URL — render as-is + } + return src +} + +/** + * Serialize an image to markdown when it has no explicit size, and to an HTML `` tag when + * it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to + * preserve its dimensions. Unsized images stay clean `![alt](src)`. An image with an `href` is + * wrapped in a markdown link so a linked badge round-trips as `[![alt](src)](href)`. + */ +function imageMarkdown(node: JSONContent): string { + const attrs = node.attrs ?? {} + const src = typeof attrs.src === 'string' ? attrs.src : '' + const alt = typeof attrs.alt === 'string' ? attrs.alt : '' + const title = typeof attrs.title === 'string' ? attrs.title : '' + const href = typeof attrs.href === 'string' ? attrs.href : '' + const hrefTitle = typeof attrs.hrefTitle === 'string' ? attrs.hrefTitle : '' + const width = attrs.width + const height = attrs.height + let image: string + if (width || height) { + const parts = [`src="${escapeAttr(src)}"`] + if (alt) parts.push(`alt="${escapeAttr(alt)}"`) + if (title) parts.push(`title="${escapeAttr(title)}"`) + if (width) parts.push(`width="${escapeAttr(String(width))}"`) + if (height) parts.push(`height="${escapeAttr(String(height))}"`) + image = `` + } else { + // Escape so an alt with `]`/`[` or a title with `"` can't break out of the `![…](… "…")` syntax + // and corrupt the round-trip; a src with spaces/parens goes in angle brackets (CommonMark). + const titlePart = title ? ` "${title.replace(/["\\]/g, '\\$&')}"` : '' + const safeSrc = /[\s()]/.test(src) ? `<${src}>` : src + image = `![${alt.replace(/[\\[\]]/g, '\\$&')}](${safeSrc}${titlePart})` + } + if (!href) return image + const hrefTitlePart = hrefTitle ? ` "${hrefTitle}"` : '' + return `[${image}](${href}${hrefTitlePart})` +} + +interface MarkdownImageToken { + /** Set only by our linked-image tokenizer; absent on the built-in `![](src)` token. */ + src?: string + alt?: string + title?: string | null + /** Built-in image token holds the source URL here; our linked token holds the link target. */ + href?: string + hrefTitle?: string | null + /** Built-in image token holds the alt text here. */ + text?: string +} + +/** Map both the built-in image token and our linked-image token onto the image node's attributes. */ +function parseImageToken(token: MarkdownImageToken): JSONContent { + const isLinked = typeof token.src === 'string' + return { + type: 'image', + attrs: isLinked + ? { + src: token.src, + alt: token.alt ?? '', + title: token.title ?? null, + href: token.href ?? null, + hrefTitle: token.hrefTitle ?? null, + } + : { + src: token.href ?? '', + alt: token.text ?? '', + title: token.title ?? null, + href: null, + hrefTitle: null, + }, + } +} + +const widthAttr = { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('width'), + renderHTML: (attributes: Record) => + attributes.width ? { width: String(attributes.width) } : {}, +} + +const heightAttr = { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('height'), + renderHTML: (attributes: Record) => + attributes.height ? { height: String(attributes.height) } : {}, +} + +/** Link target of a linked image — markdown-only state, never emitted as an HTML `` attribute. */ +const hrefAttr = { default: null, rendered: false } +const hrefTitleAttr = { default: null, rendered: false } + +/** + * Image node that carries optional `width`/`height` (serialized as an HTML `` tag) and an + * optional `href`/`hrefTitle` (a wrapping markdown link, for badges). Shared by the headless + * round-trip path (no node view) and the live {@link ResizableImage}. + */ +export const MarkdownImage = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: widthAttr, + height: heightAttr, + href: hrefAttr, + hrefTitle: hrefTitleAttr, + } + }, + markdownTokenizer: { + name: 'image', + level: 'inline', + start: (src: string) => src.indexOf('[!['), + tokenize: (src: string): (MarkdownImageToken & { type: string; raw: string }) | undefined => { + const match = LINKED_IMAGE_RE.exec(src) + if (!match) return undefined + return { + type: 'image', + raw: match[0], + alt: match[1] ?? '', + src: match[2], + title: match[3] ?? null, + href: match[4], + hrefTitle: match[5] ?? null, + } + }, + }, + parseMarkdown: parseImageToken, + renderMarkdown: imageMarkdown, +}) + +/** + * Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging + * commits the new pixel width to the `width` attribute, which serializes to ``. + */ +function ResizableImageView({ node, updateAttributes, selected, editor }: ReactNodeViewProps) { + const imageRef = useRef(null) + const dragAbortRef = useRef(null) + const [dragging, setDragging] = useState(false) + const attrs = node.attrs as { + src?: string + alt?: string + title?: string + width?: string | null + href?: string | null + } + + useEffect(() => () => dragAbortRef.current?.abort(), []) + + const startResize = (event: React.PointerEvent) => { + event.preventDefault() + const image = imageRef.current + if (!image) return + const startX = event.clientX + const startWidth = image.offsetWidth + setDragging(true) + dragAbortRef.current?.abort() + const controller = new AbortController() + dragAbortRef.current = controller + const { signal } = controller + + window.addEventListener( + 'pointermove', + (move) => { + const next = Math.max(MIN_WIDTH, Math.round(startWidth + (move.clientX - startX))) + updateAttributes({ width: String(next) }) + }, + { signal } + ) + window.addEventListener( + 'pointerup', + () => { + setDragging(false) + controller.abort() + }, + { signal } + ) + } + + const widthStyle = attrs.width + ? { width: /^\d+$/.test(attrs.width) ? `${attrs.width}px` : attrs.width } + : undefined + + // Sanitize the linked-image target before rendering the anchor — a parsed markdown href is + // untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only). + const safeHref = normalizeLinkHref(typeof attrs.href === 'string' ? attrs.href : '') + + // Read-only: no drag-to-reorder and no resize handle — both call updateAttributes / dispatch a move, + // mutating a doc that must not change. The image still renders (and follows its link on click). + const editable = editor.isEditable + + const image = ( + {attrs.alt + ) + + return ( + + {safeHref ? ( + // The editor's handleClick is the sole navigator (gated on editable/modifier, like text links + // via openOnClick:false): prevent the anchor's own navigation so a plain click in edit mode + // places the caret / selects the node instead of opening a tab. + event.preventDefault()} + > + {image} + + ) : ( + image + )} + {editable && (selected || dragging) && ( +