diff --git a/package-lock.json b/package-lock.json index c73a4a2e..0e698df8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15959,6 +15959,10 @@ "resolved": "packages/v1-ready/stripe", "link": true }, + "node_modules/@friggframework/api-module-trello": { + "resolved": "packages/v1-ready/trello", + "link": true + }, "node_modules/@friggframework/api-module-unbabel": { "resolved": "packages/v1-ready/unbabel", "link": true @@ -58230,6 +58234,101 @@ "node": ">=12" } }, + "packages/v1-ready/trello": { + "name": "@friggframework/api-module-trello", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@friggframework/core": "^2.0.0-next.107" + }, + "devDependencies": { + "@aws-sdk/client-scheduler": "^3.1113.0", + "dotenv": "^16.0.3", + "jest": "^28.1.3", + "js-yaml": "^4.1.0" + } + }, + "packages/v1-ready/trello/node_modules/@friggframework/core": { + "version": "2.0.0-next.118", + "resolved": "https://registry.npmjs.org/@friggframework/core/-/core-2.0.0-next.118.tgz", + "integrity": "sha512-SPxk7F5st3zQyB2p/orzS5Xs9PAo54RtZ82lfen6VW3Qf475GS5SlPGHttnSRjEQPlpfZ7piRs2Wqe3qjv9E3g==", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0", + "@aws-sdk/client-kms": "^3.588.0", + "@aws-sdk/client-lambda": "^3.714.0", + "@aws-sdk/client-s3": "^3.588.0", + "@aws-sdk/client-sqs": "^3.588.0", + "@aws-sdk/client-ssm": "^3.588.0", + "@aws-sdk/s3-request-presigner": "^3.588.0", + "@hapi/boom": "^10.0.1", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-metrics": "^2.9.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "bcryptjs": "^2.4.3", + "body-parser": "^1.20.5", + "bson": "^4.7.2", + "chalk": "^4.1.2", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.22.2", + "express-async-handler": "^1.2.0", + "form-data": "^4.0.6", + "fs-extra": "^11.2.0", + "lodash": "4.18.1", + "lodash.get": "^4.4.2", + "node-fetch": "^2.6.7", + "serverless-http": "^2.7.0", + "uuid": "^11.1.1" + }, + "peerDependencies": { + "@prisma/client": "^6.19.3", + "prisma": "^6.19.3" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "packages/v1-ready/trello/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "packages/v1-ready/trello/node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "packages/v1-ready/trello/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "packages/v1-ready/unbabel": { "name": "@friggframework/api-module-unbabel", "version": "1.1.5", diff --git a/packages/v1-ready/trello/.env.example b/packages/v1-ready/trello/.env.example new file mode 100644 index 00000000..874adbe3 --- /dev/null +++ b/packages/v1-ready/trello/.env.example @@ -0,0 +1,11 @@ +# Trello API key — generate under Power-Up admin → Trello Auth +# https://trello.com/apps/admin +TRELLO_API_KEY=your_trello_api_key_here + +# User token — authorize at: +# https://trello.com/1/authorize?expiration=never&scope=read,write,account&response_type=token&key=YOUR_API_KEY +TRELLO_TOKEN=your_trello_user_token_here + +# Application secret (OAuth 1.0 secret) — used to verify X-Trello-Webhook signatures. +# Same Power-Up admin → Trello Auth tab. +TRELLO_API_SECRET=your_trello_api_secret_here diff --git a/packages/v1-ready/trello/LICENSE.md b/packages/v1-ready/trello/LICENSE.md new file mode 100644 index 00000000..c307ce69 --- /dev/null +++ b/packages/v1-ready/trello/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Left Hook + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/v1-ready/trello/README.md b/packages/v1-ready/trello/README.md new file mode 100644 index 00000000..6ed86420 --- /dev/null +++ b/packages/v1-ready/trello/README.md @@ -0,0 +1,127 @@ +# Trello API Module (`@friggframework/api-module-trello`) + +A [Frigg](https://friggframework.org) API module for [Trello](https://trello.com) +— Atlassian's boards / lists / cards product. It wraps the public REST API so a +Frigg integration can authenticate a member, list and mutate boards/lists/cards, +read workspaces (organizations), manage labels, and subscribe to webhooks. + +Integrations consume it the standard Frigg way: + +```javascript +const boards = await this.trello.api.listBoards('me'); +const card = await this.trello.api.createCard({ + idList: listId, + name: 'Follow up', +}); +``` + +## Authentication + +Trello uses an **API key + user token** (Trello Auth / Power-Up key): + +1. Create (or open) a Power-Up at https://trello.com/apps/admin → **Trello Auth** + and copy the API key. +2. Authorize a user token: + `https://trello.com/1/authorize?expiration=never&scope=read,write,account&response_type=token&key={YourAPIKey}` +3. The module sends both on every request as `key` + `token` query parameters, + matching the official OpenAPI security schemes (`APIKey` / `APIToken` in + [swagger.v3.json](https://developer.atlassian.com/cloud/trello/swagger.v3.json)). + The Authorization guide also documents an equivalent + `Authorization: OAuth oauth_consumer_key="…", oauth_token="…"` header; this + client follows the OpenAPI schemes (query params). + +- **Base URL:** `https://api.trello.com/1` +- **Official OpenAPI:** https://developer.atlassian.com/cloud/trello/swagger.v3.json + +Set `TRELLO_API_KEY`, `TRELLO_TOKEN`, and (for webhook signature verification) +`TRELLO_API_SECRET` in your environment (see `.env.example`). The module also +exposes a JSON-Schema authorization form (`getAuthorizationRequirements`) so +key + token can be collected through the Frigg auth UI / `frigg auth` CLI. + +No partner approval is required for classic key+token REST access. Atlassian +OAuth 2.0 (3LO) scopes are a separate product surface and are **not** wired in +this module yet — see open questions in the PR. + +## API Methods + +| Method | HTTP | Purpose | +|---|---|---| +| `getMember(id, params)` | `GET /members/{id}` | Member identity (`me` for the token owner). | +| `listBoards(memberId, params)` | `GET /members/{id}/boards` | Boards for a member. | +| `getBoard(id, params)` | `GET /boards/{id}` | Single board. | +| `createBoard(params)` | `POST /boards/` | Create a board. | +| `updateBoard(id, params)` | `PUT /boards/{id}` | Update a board. | +| `listLists(boardId, params)` | `GET /boards/{id}/lists` | Lists on a board. | +| `getList(id, params)` | `GET /lists/{id}` | Single list. | +| `createList(params)` | `POST /lists` | Create a list (`name`, `idBoard`). | +| `updateList(id, params)` | `PUT /lists/{id}` | Update a list. | +| `listCards(boardId, params)` | `GET /boards/{id}/cards` | Cards on a board. | +| `listListCards(listId, params)` | `GET /lists/{id}/cards` | Cards on a list. | +| `getCard(id, params)` | `GET /cards/{id}` | Single card. | +| `createCard(params)` | `POST /cards` | Create a card (`idList` required). | +| `updateCard(id, params)` | `PUT /cards/{id}` | Update a card. | +| `listOrganizations(memberId, params)` | `GET /members/{id}/organizations` | Workspaces. | +| `getOrganization(id, params)` | `GET /organizations/{id}` | Single workspace. | +| `listLabels(boardId, params)` | `GET /boards/{id}/labels` | Labels on a board. | +| `createLabel(params)` | `POST /labels` | Create a label. | +| `createWebhook(data)` | `POST /webhooks/` | Subscribe to a model. | +| `getWebhook(id)` | `GET /webhooks/{id}` | Get a webhook. | +| `updateWebhook(id, data)` | `PUT /webhooks/{id}` | Update a webhook. | +| `deleteWebhook(id)` | `DELETE /webhooks/{id}` | Delete a webhook. | +| `listTokenWebhooks(token?)` | `GET /tokens/{token}/webhooks` | List webhooks for a token. | +| `createTokenWebhook(data, token?)` | `POST /tokens/{token}/webhooks` | Create under a token. | +| `verifyWebhookSignature(args)` | — | Verify `X-Trello-Webhook` HMAC. | + +## Webhooks + +Create with `createWebhook` (or `createTokenWebhook`). Trello HEADs +`callbackURL` and expects HTTP 200 before creating. Prefer appending +`?memberId=` so the Tier-3 extension can resolve the owning Frigg +entity: + +```javascript +await this.trello.api.createWebhook({ + callbackURL: `https://your-frigg-app/webhooks/trello?memberId=${memberId}`, + idModel: boardId, + description: 'Frigg board watcher', +}); +``` + +**Signature:** `X-Trello-Webhook` = base64(HMAC-SHA1(api_secret, rawBody + callbackURL)). +Use the Power-Up **application secret** from the Trello Auth tab +(`TRELLO_API_SECRET`). + +The `extensions/webhooks/` bundle is a HubSpot/Front-style DB-free receiver +(`HEAD` + `POST /webhooks` → resolve → `TRELLO_WEBHOOK`). + +## Capability manifest & Fenestra + +- `trello.capabilities.json` — `capability-manifest/v0.1` covering member, + board, list, card, organization, label, webhook objects and mapped Api methods. +- `trello.fenestra.yaml` — draft Power-Ups UI ecosystem (iframe connector, + board-buttons / card-buttons / attachment-sections) per + `specs/fenestra/PLATFORMS.md`. + +## Testing + +```bash +cd packages/v1-ready/trello +npm install +npm test +``` + +Tests are fully offline — HTTP methods are stubbed. No API key or network +access is required. + +## Sources + +- REST API — https://developer.atlassian.com/cloud/trello/rest/ +- Official OpenAPI — https://developer.atlassian.com/cloud/trello/swagger.v3.json +- Authorization — https://developer.atlassian.com/cloud/trello/guides/rest-api/authorization/ +- Webhooks — https://developer.atlassian.com/cloud/trello/guides/rest-api/webhooks/ +- Object definitions — https://developer.atlassian.com/cloud/trello/guides/rest-api/object-definitions/ +- Power-Ups — https://developer.atlassian.com/cloud/trello/power-ups/ + +## License + +MIT — see [LICENSE.md](./LICENSE.md). diff --git a/packages/v1-ready/trello/api.js b/packages/v1-ready/trello/api.js new file mode 100644 index 00000000..652ee42c --- /dev/null +++ b/packages/v1-ready/trello/api.js @@ -0,0 +1,565 @@ +const crypto = require('crypto'); +const { Requester, ModuleConstants, get } = require('@friggframework/core'); + +const BASE_URL = 'https://api.trello.com/1'; +const AUTHORIZE_URL = 'https://trello.com/1/authorize'; +const SIGNATURE_HEADER = 'x-trello-webhook'; + +/** + * Trello REST API client. + * + * Auth: API key + user token (Trello Auth / Power-Up key), matching the + * official OpenAPI security schemes `APIKey` (`key` query) + `APIToken` + * (`token` query) in https://developer.atlassian.com/cloud/trello/swagger.v3.json. + * Every request injects `key` and `token` as query parameters (the primary + * method in the Authorization guide; `Authorization: OAuth …` is an + * equivalent alternative documented there but not the OpenAPI security scheme). + * + * - API key: Power-Up admin → Trello Auth (https://trello.com/apps/admin) + * - Token: GET https://trello.com/1/authorize?…&key={APIKey} + * - Base URL: https://api.trello.com/1 + * + * Official OpenAPI: https://developer.atlassian.com/cloud/trello/swagger.v3.json + * Docs: https://developer.atlassian.com/cloud/trello/rest/ + * Auth: https://developer.atlassian.com/cloud/trello/guides/rest-api/authorization/ + * + * The curated contract in ./trello.openapi.yaml mirrors this client 1:1 + * (one method per operationId). Keep them in sync. + */ +class Api extends Requester { + constructor(params = {}) { + super(params); + + this.api_key = + get(params, 'api_key', null) || + get(params, 'key', null) || + process.env.TRELLO_API_KEY || + null; + this.token = + get(params, 'token', null) || + get(params, 'access_token', null) || + process.env.TRELLO_TOKEN || + null; + this.api_secret = + get(params, 'api_secret', null) || + get(params, 'secret', null) || + process.env.TRELLO_API_SECRET || + null; + + this.baseUrl = get(params, 'baseUrl', null) || BASE_URL; + + this.URLs = { + memberById: (id) => `/members/${id}`, + memberBoards: (id) => `/members/${id}/boards`, + memberOrganizations: (id) => `/members/${id}/organizations`, + boards: '/boards/', + boardById: (id) => `/boards/${id}`, + boardLists: (id) => `/boards/${id}/lists`, + boardCards: (id) => `/boards/${id}/cards`, + boardLabels: (id) => `/boards/${id}/labels`, + lists: '/lists', + listById: (id) => `/lists/${id}`, + listCards: (id) => `/lists/${id}/cards`, + cards: '/cards', + cardById: (id) => `/cards/${id}`, + organizations: '/organizations', + organizationById: (id) => `/organizations/${id}`, + labels: '/labels', + webhooks: '/webhooks/', + webhookById: (id) => `/webhooks/${id}`, + tokenWebhooks: (token) => `/tokens/${token}/webhooks`, + }; + } + + // ---- Auth helpers ------------------------------------------------------ + + getAuthorizationRequirements() { + const key = this.api_key || process.env.TRELLO_API_KEY || ''; + const params = new URLSearchParams({ + expiration: 'never', + scope: 'read,write,account', + response_type: 'token', + key, + }); + const authorizeUrl = key + ? `${AUTHORIZE_URL}?${params.toString()}` + : null; + + return { + url: authorizeUrl, + type: ModuleConstants.authType.apiKey, + data: { + jsonSchema: { + title: 'Trello Authentication', + type: 'object', + required: ['api_key', 'token'], + properties: { + api_key: { + type: 'string', + title: 'API Key', + }, + token: { + type: 'string', + title: 'User Token', + }, + }, + }, + uiSchema: { + api_key: { + 'ui:help': + 'Generate under Power-Up admin → Trello Auth (https://trello.com/apps/admin).', + 'ui:placeholder': '32-char hex API key', + }, + token: { + 'ui:widget': 'password', + 'ui:help': + 'Authorize at https://trello.com/1/authorize?expiration=never&scope=read,write,account&response_type=token&key=YOUR_API_KEY then paste the token.', + }, + }, + }, + }; + } + + /** + * Merge official OpenAPI `key` + `token` query credentials onto a request. + * @private + */ + _withAuth(options = {}) { + const query = { ...(options.query || {}) }; + if (this.api_key) query.key = this.api_key; + if (this.token) query.token = this.token; + return { ...options, query }; + } + + async addAuthHeaders(headers = {}) { + // Credentials travel as query params per swagger.v3.json securitySchemes. + headers.Accept = headers.Accept || 'application/json'; + return headers; + } + + async _get(options) { + return super._get(this._withAuth(options)); + } + + async _post(options, stringify = true) { + return super._post(this._withAuth(options), stringify); + } + + async _put(options, stringify = true) { + return super._put(this._withAuth(options), stringify); + } + + async _delete(options) { + return super._delete(this._withAuth(options)); + } + + isAuthenticated() { + return ( + typeof this.api_key === 'string' && + this.api_key.trim().length > 0 && + typeof this.token === 'string' && + this.token.trim().length > 0 + ); + } + + setApiKey(apiKey) { + this.api_key = apiKey; + } + + setToken(token) { + this.token = token; + } + + addJsonHeaders(options = {}) { + options.headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...options.headers, + }; + return options; + } + + // ---- Members / identity ------------------------------------------------ + + /** + * Get a member. Pass `me` for the authenticated member. + * GET /members/{id} + * @see https://developer.atlassian.com/cloud/trello/rest/api-group-members/#api-members-id-get + */ + async getMember(id = 'me', params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.memberById(id), + query: params, + }); + } + + // ---- Boards ------------------------------------------------------------ + + /** + * List boards for a member (default: me). + * GET /members/{id}/boards + */ + async listBoards(memberId = 'me', params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.memberBoards(memberId), + query: params, + }); + } + + /** + * Get a board by id. + * GET /boards/{id} + */ + async getBoard(id, params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.boardById(id), + query: params, + }); + } + + /** + * Create a board. + * POST /boards/ + * Query params per official OpenAPI (name, desc, idOrganization, …). + */ + async createBoard(params = {}) { + return this._post( + this.addJsonHeaders({ + url: this.baseUrl + this.URLs.boards, + query: params, + }) + ); + } + + /** + * Update a board. + * PUT /boards/{id} + */ + async updateBoard(id, params = {}) { + return this._put( + this.addJsonHeaders({ + url: this.baseUrl + this.URLs.boardById(id), + query: params, + }) + ); + } + + // ---- Lists ------------------------------------------------------------- + + /** + * List lists on a board. + * GET /boards/{id}/lists + */ + async listLists(boardId, params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.boardLists(boardId), + query: params, + }); + } + + /** + * Get a list by id. + * GET /lists/{id} + */ + async getList(id, params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.listById(id), + query: params, + }); + } + + /** + * Create a list. + * POST /lists + */ + async createList(params = {}) { + return this._post( + this.addJsonHeaders({ + url: this.baseUrl + this.URLs.lists, + query: params, + }) + ); + } + + /** + * Update a list. + * PUT /lists/{id} + */ + async updateList(id, params = {}) { + return this._put( + this.addJsonHeaders({ + url: this.baseUrl + this.URLs.listById(id), + query: params, + }) + ); + } + + // ---- Cards ------------------------------------------------------------- + + /** + * List cards on a board. + * GET /boards/{id}/cards + */ + async listCards(boardId, params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.boardCards(boardId), + query: params, + }); + } + + /** + * List cards on a list. + * GET /lists/{id}/cards + */ + async listListCards(listId, params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.listCards(listId), + query: params, + }); + } + + /** + * Get a card by id. + * GET /cards/{id} + */ + async getCard(id, params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.cardById(id), + query: params, + }); + } + + /** + * Create a card. + * POST /cards + */ + async createCard(params = {}) { + return this._post( + this.addJsonHeaders({ + url: this.baseUrl + this.URLs.cards, + query: params, + }) + ); + } + + /** + * Update a card. + * PUT /cards/{id} + */ + async updateCard(id, params = {}) { + return this._put( + this.addJsonHeaders({ + url: this.baseUrl + this.URLs.cardById(id), + query: params, + }) + ); + } + + // ---- Organizations (workspaces) ---------------------------------------- + + /** + * List organizations for a member (default: me). + * GET /members/{id}/organizations + */ + async listOrganizations(memberId = 'me', params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.memberOrganizations(memberId), + query: params, + }); + } + + /** + * Get an organization by id. + * GET /organizations/{id} + */ + async getOrganization(id, params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.organizationById(id), + query: params, + }); + } + + // ---- Labels ------------------------------------------------------------ + + /** + * List labels on a board. + * GET /boards/{id}/labels + */ + async listLabels(boardId, params = {}) { + return this._get({ + url: this.baseUrl + this.URLs.boardLabels(boardId), + query: params, + }); + } + + /** + * Create a label. + * POST /labels + */ + async createLabel(params = {}) { + return this._post( + this.addJsonHeaders({ + url: this.baseUrl + this.URLs.labels, + query: params, + }) + ); + } + + // ---- Webhooks ---------------------------------------------------------- + + /** + * Create a webhook (key/token auth). + * POST /webhooks/ + * + * Query params per official OpenAPI (post-webhooks): required + * callbackURL, idModel; optional description, active. + * Trello HEADs callbackURL and expects HTTP 200 before creating. + * + * @see https://developer.atlassian.com/cloud/trello/swagger.v3.json + * @see https://developer.atlassian.com/cloud/trello/guides/rest-api/webhooks/ + */ + async createWebhook(params = {}) { + return this._post({ + url: this.baseUrl + this.URLs.webhooks, + query: params, + }); + } + + /** + * Get a webhook by id. + * GET /webhooks/{id} + */ + async getWebhook(id) { + return this._get({ + url: this.baseUrl + this.URLs.webhookById(id), + }); + } + + /** + * Update a webhook. + * PUT /webhooks/{id} + * Query params per official OpenAPI (put-webhooks-id). + */ + async updateWebhook(id, params = {}) { + return this._put({ + url: this.baseUrl + this.URLs.webhookById(id), + query: params, + }); + } + + /** + * Delete a webhook. + * DELETE /webhooks/{id} + */ + async deleteWebhook(id) { + return this._delete({ + url: this.baseUrl + this.URLs.webhookById(id), + }); + } + + /** + * List webhooks for a token (defaults to this.token). + * GET /tokens/{token}/webhooks + */ + async listTokenWebhooks(token) { + const t = token || this.token; + return this._get({ + url: this.baseUrl + this.URLs.tokenWebhooks(t), + }); + } + + /** + * Create a webhook nested under a token. + * POST /tokens/{token}/webhooks + * Query params per official OpenAPI (post-tokens-token-webhooks). + */ + async createTokenWebhook(params = {}, token) { + const t = token || this.token; + return this._post({ + url: this.baseUrl + this.URLs.tokenWebhooks(t), + query: params, + }); + } + + /** + * Verify an inbound Trello webhook signature (X-Trello-Webhook). + * + * Algorithm (official docs): + * content = rawBody + callbackURL (exact callbackURL used at create) + * signature = base64(HMAC-SHA1(api_secret, content)) + * + * @see https://developer.atlassian.com/cloud/trello/guides/rest-api/webhooks/ + */ + verifyWebhookSignature({ + headers = {}, + rawBody, + body, + callbackURL, + secret, + } = {}) { + const key = secret || this.api_secret; + if (!key || typeof key !== 'string') { + return { valid: false, reason: 'missing api secret' }; + } + if (!callbackURL || typeof callbackURL !== 'string') { + return { valid: false, reason: 'missing callbackURL' }; + } + + const signature = _header(headers, SIGNATURE_HEADER); + if (!signature) { + return { valid: false, reason: `missing ${SIGNATURE_HEADER}` }; + } + + const payload = _payloadString(rawBody, body); + const expected = crypto + .createHmac('sha1', key) + .update(payload + callbackURL) + .digest('base64'); + + if (!_safeEqual(expected, signature)) { + return { valid: false, reason: 'signature mismatch' }; + } + return { valid: true }; + } +} + +function _header(headers, name) { + if (!headers || typeof headers !== 'object') return undefined; + const want = name.toLowerCase(); + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === want) { + const v = headers[key]; + return Array.isArray(v) ? v[0] : v; + } + } + return undefined; +} + +function _payloadString(rawBody, body) { + if (rawBody !== undefined && rawBody !== null) { + return Buffer.isBuffer(rawBody) + ? rawBody.toString('utf8') + : String(rawBody); + } + if (body === undefined || body === null) return ''; + if (Buffer.isBuffer(body)) return body.toString('utf8'); + if (typeof body === 'string') return body; + return JSON.stringify(body); +} + +function _safeEqual(a, b) { + try { + const x = Buffer.from(String(a), 'utf8'); + const y = Buffer.from(String(b), 'utf8'); + return x.length === y.length && crypto.timingSafeEqual(x, y); + } catch (_e) { + return false; + } +} + +module.exports = { + Api, + SIGNATURE_HEADER, + BASE_URL, + AUTHORIZE_URL, +}; diff --git a/packages/v1-ready/trello/defaultConfig.json b/packages/v1-ready/trello/defaultConfig.json new file mode 100644 index 00000000..b6bf17a3 --- /dev/null +++ b/packages/v1-ready/trello/defaultConfig.json @@ -0,0 +1,10 @@ +{ + "name": "trello", + "config": { + "apiKey": true, + "batch": { + "concurrency": 3, + "delay": 1000 + } + } +} diff --git a/packages/v1-ready/trello/definition.js b/packages/v1-ready/trello/definition.js new file mode 100644 index 00000000..8f6ff2a3 --- /dev/null +++ b/packages/v1-ready/trello/definition.js @@ -0,0 +1,84 @@ +require('dotenv').config(); +const { Api } = require('./api'); +const { get } = require('@friggframework/core'); +const config = require('./defaultConfig.json'); + +/** + * Resolve the authenticated Trello member as a stable account identity. + * GET /members/me returns `id` (TrelloID) plus `fullName` / `username`. + * There is deliberately no shared constant fallback — two customers must + * never map to the same entity/credential. + */ +async function resolveMemberIdentity(api) { + const me = await api.getMember('me'); + if (!me?.id) { + throw new Error( + 'Trello: GET /members/me did not return a member id — cannot derive a stable account identity.' + ); + } + return { + externalId: String(me.id), + name: me.fullName || me.username || String(me.id), + username: me.username || null, + }; +} + +const Definition = { + API: Api, + getName: () => config.name, + moduleName: config.name, + modelName: 'Trello', + requiredAuthMethods: { + getAuthorizationRequirements: (api) => + api.getAuthorizationRequirements(), + + setAuthParams: async (api, params) => { + const data = (params && params.data) || {}; + const apiKey = + get(params, 'api_key', null) || + get(data, 'api_key', null) || + get(params, 'key', null); + const token = + get(params, 'token', null) || + get(data, 'token', null) || + get(params, 'access_token', null); + + if (apiKey) api.setApiKey(apiKey); + if (token) api.setToken(token); + return api; + }, + + getEntityDetails: async (api, callbackParams, tokenResponse, userId) => { + const identity = await resolveMemberIdentity(api); + return { + identifiers: { externalId: identity.externalId, userId }, + details: { + name: identity.name, + username: identity.username, + }, + }; + }, + + getCredentialDetails: async (api, userId) => { + const identity = await resolveMemberIdentity(api); + return { + identifiers: { externalId: identity.externalId, userId }, + details: {}, + }; + }, + + apiPropertiesToPersist: { + credential: ['api_key', 'token'], + entity: [], + }, + + testAuthRequest: async (api) => api.getMember('me'), + }, + env: { + api_key: process.env.TRELLO_API_KEY, + token: process.env.TRELLO_TOKEN, + api_secret: process.env.TRELLO_API_SECRET, + }, +}; + +module.exports = { Definition }; diff --git a/packages/v1-ready/trello/extensions/webhooks/handlers.js b/packages/v1-ready/trello/extensions/webhooks/handlers.js new file mode 100644 index 00000000..a87e5220 --- /dev/null +++ b/packages/v1-ready/trello/extensions/webhooks/handlers.js @@ -0,0 +1,95 @@ +const { verifyTrelloSignature } = require('./signature-verifier'); +const { + findIntegrationByMemberId, + extractMemberId, +} = require('./lookup'); + +/** + * HEAD /webhooks — Trello probes callbackURL with HTTP HEAD during + * webhook creation and expects 200. No body, no signature. + * @see https://developer.atlassian.com/cloud/trello/guides/rest-api/webhooks/ + */ +async function onTrelloWebhookHead({ res }) { + res.status(200).end(); +} + +/** + * Receiver for `POST /webhooks` — DB-free. + * + * 1. Verify X-Trello-Webhook (HMAC-SHA1 of body + callbackURL). + * 2. Enqueue TRELLO_WEBHOOK_RESOLVE with the parsed body. + * + * @see https://developer.atlassian.com/cloud/trello/guides/rest-api/webhooks/ + */ +async function onTrelloWebhookReceived({ req, res }) { + const secret = process.env.TRELLO_API_SECRET; + const verification = verifyTrelloSignature({ req, secret }); + if (!verification.valid) { + console.warn( + `[trello-webhooks] rejecting webhook: ${verification.reason}` + ); + res.status(401).json({ error: 'invalid signature' }); + return; + } + + const body = req.body || {}; + const memberId = extractMemberId(body); + if (!memberId) { + console.warn( + '[trello-webhooks] event missing memberId (add ?memberId= to callbackURL); skipping resolve' + ); + res.status(200).json({ received: 1, queued: 0, skipped: 1 }); + return; + } + + await this.queueWebhook({ + event: 'TRELLO_WEBHOOK_RESOLVE', + body, + }); + + res.status(200).json({ received: 1, queued: 1, skipped: 0 }); +} + +/** + * TRELLO_WEBHOOK_RESOLVE — worker hop with DB. Looks up member id → + * integration, then re-enqueues TRELLO_WEBHOOK bound to that id. + */ +async function onTrelloWebhookResolve({ data }) { + const body = data && data.body; + const memberId = extractMemberId(body); + if (!memberId) { + console.warn( + '[trello-webhooks] resolve: event missing memberId; skipping' + ); + return; + } + + const integrationId = await findIntegrationByMemberId(this, memberId); + if (!integrationId) { + console.warn( + `[trello-webhooks] resolve: no integration for memberId=${memberId}; skipping` + ); + return; + } + + await this.queueWebhook({ + event: 'TRELLO_WEBHOOK', + integrationId, + body, + }); +} + +/** + * TRELLO_WEBHOOK — hydrated integration. Default no-op; override via + * binding.handlers.TRELLO_WEBHOOK. + */ +async function onTrelloWebhook({ data }) { + // no-op — override via binding.handlers.TRELLO_WEBHOOK +} + +module.exports = { + onTrelloWebhookHead, + onTrelloWebhookReceived, + onTrelloWebhookResolve, + onTrelloWebhook, +}; diff --git a/packages/v1-ready/trello/extensions/webhooks/index.js b/packages/v1-ready/trello/extensions/webhooks/index.js new file mode 100644 index 00000000..c18e3bb0 --- /dev/null +++ b/packages/v1-ready/trello/extensions/webhooks/index.js @@ -0,0 +1,58 @@ +const { + onTrelloWebhookHead, + onTrelloWebhookReceived, + onTrelloWebhookResolve, + onTrelloWebhook, +} = require('./handlers'); + +/** + * Trello Webhooks — Tier 3 Integration Extension bundle. + * + * DB-free receiver (`useDatabase: false`): TRELLO_WEBHOOK_RECEIVED verifies + * the X-Trello-Webhook HMAC and enqueues TRELLO_WEBHOOK_RESOLVE. Member → + * integration lookup needs the DB, so it runs in the worker, which re-enqueues + * TRELLO_WEBHOOK bound to the resolved integration. Consumers override + * TRELLO_WEBHOOK via binding.handlers. + * + * HEAD /webhooks answers Trello's create-time probe (must return 200). + * + * Prefer creating webhooks with `callbackURL` containing `?memberId=` + * so resolve can map the delivery to the owning Frigg entity. + * + * Contract: https://github.com/friggframework/frigg/blob/next/packages/core/integrations/EXTENSIONS.md + * Vendor: https://developer.atlassian.com/cloud/trello/guides/rest-api/webhooks/ + */ +module.exports = { + name: 'trello-webhooks', + useDatabase: false, + routes: [ + { + path: '/webhooks', + method: 'HEAD', + event: 'TRELLO_WEBHOOK_HEAD', + }, + { + path: '/webhooks', + method: 'POST', + event: 'TRELLO_WEBHOOK_RECEIVED', + }, + ], + events: { + TRELLO_WEBHOOK_HEAD: { + type: 'LIFE_CYCLE_EVENT', + handler: onTrelloWebhookHead, + }, + TRELLO_WEBHOOK_RECEIVED: { + type: 'LIFE_CYCLE_EVENT', + handler: onTrelloWebhookReceived, + }, + TRELLO_WEBHOOK_RESOLVE: { + type: 'LIFE_CYCLE_EVENT', + handler: onTrelloWebhookResolve, + }, + TRELLO_WEBHOOK: { + type: 'LIFE_CYCLE_EVENT', + handler: onTrelloWebhook, + }, + }, +}; diff --git a/packages/v1-ready/trello/extensions/webhooks/lookup.js b/packages/v1-ready/trello/extensions/webhooks/lookup.js new file mode 100644 index 00000000..db9f4f0a --- /dev/null +++ b/packages/v1-ready/trello/extensions/webhooks/lookup.js @@ -0,0 +1,82 @@ +/** + * Resolve a Trello member id to the owning Frigg integration id. + * + * Prefer putting `?memberId=` on the webhook callbackURL when + * creating subscriptions (Trello docs recommend app-specific callback query + * params). Falls back to matching `entity.externalId` against idModel when + * the webhook watches the member themselves. + * + * @param {Object} ctx - Extension / worker `this` + * @param {string} memberId - Trello member id + * @returns {Promise} + */ +async function findIntegrationByMemberId(ctx, memberId) { + if (!memberId) return null; + const want = String(memberId); + + const mo = ctx && ctx.integrationMO; + if (!mo || typeof mo.list !== 'function') { + return null; + } + + const integrations = await mo.list({}); + const list = Array.isArray(integrations) + ? integrations + : integrations?.integrations || []; + + for (const integration of list) { + const entities = integration?.entities || []; + for (const entity of entities) { + if ( + entity && + String(entity.externalId) === want && + (entity.type === 'trello' || + entity.module === 'trello' || + !entity.type) + ) { + return integration.id || integration._id || null; + } + } + if ( + String(integration?.externalId) === want || + String(integration?.config?.memberId) === want + ) { + return integration.id || integration._id || null; + } + } + return null; +} + +/** + * Extract a member id from a Trello webhook payload. + * Prefer `memberId` / `externalId` query params on callbackURL; else use + * idModel when it looks like the watched model is the member. + */ +function extractMemberId(body) { + if (!body || typeof body !== 'object') return null; + + const callbackURL = body.webhook && body.webhook.callbackURL; + if (callbackURL && typeof callbackURL === 'string') { + try { + const u = new URL(callbackURL); + const fromQuery = + u.searchParams.get('memberId') || + u.searchParams.get('externalId') || + u.searchParams.get('memberID'); + if (fromQuery) return fromQuery; + } catch (_e) { + // ignore malformed callbackURL + } + } + + // When the webhook watches the member model, idModel is the member id. + if (body.webhook && body.webhook.idModel) { + return String(body.webhook.idModel); + } + return null; +} + +module.exports = { + findIntegrationByMemberId, + extractMemberId, +}; diff --git a/packages/v1-ready/trello/extensions/webhooks/signature-verifier.js b/packages/v1-ready/trello/extensions/webhooks/signature-verifier.js new file mode 100644 index 00000000..e2842407 --- /dev/null +++ b/packages/v1-ready/trello/extensions/webhooks/signature-verifier.js @@ -0,0 +1,38 @@ +const { Api, SIGNATURE_HEADER } = require('../../api'); + +/** + * Verify a Trello webhook request (X-Trello-Webhook). + * + * Thin wrapper around `Api.verifyWebhookSignature` so the extension shares + * the same HMAC-SHA1(body + callbackURL) algorithm as the client helper. + * + * @param {Object} args + * @param {import('express').Request|Object} args.req + * @param {string} args.secret - Application secret (Trello Auth tab) + * @param {string} [args.callbackURL] - Exact callbackURL used at create time + * @returns {{ valid: boolean, reason?: string }} + */ +function verifyTrelloSignature({ req, secret, callbackURL }) { + if (!req || typeof req !== 'object') { + return { valid: false, reason: 'missing request' }; + } + + const body = req.body || {}; + const url = + callbackURL || + (body.webhook && body.webhook.callbackURL) || + null; + + return new Api({ api_secret: secret }).verifyWebhookSignature({ + headers: req.headers || {}, + rawBody: req.rawBody, + body: req.body, + callbackURL: url, + secret, + }); +} + +module.exports = { + verifyTrelloSignature, + SIGNATURE_HEADER, +}; diff --git a/packages/v1-ready/trello/index.js b/packages/v1-ready/trello/index.js new file mode 100644 index 00000000..3c94a639 --- /dev/null +++ b/packages/v1-ready/trello/index.js @@ -0,0 +1,7 @@ +const { Api } = require('./api'); +const { Definition } = require('./definition'); + +module.exports = { + Api, + Definition, +}; diff --git a/packages/v1-ready/trello/package.json b/packages/v1-ready/trello/package.json new file mode 100644 index 00000000..4154dd95 --- /dev/null +++ b/packages/v1-ready/trello/package.json @@ -0,0 +1,33 @@ +{ + "name": "@friggframework/api-module-trello", + "version": "1.0.0", + "description": "Trello REST API module that lets the Frigg Framework interact with Trello members, boards, lists, cards, organizations, labels, and webhooks", + "main": "index.js", + "scripts": { + "test": "jest", + "lint:fix": "prettier --write --loglevel error . && eslint . --fix" + }, + "keywords": [ + "frigg", + "trello", + "atlassian", + "api-module", + "boards", + "cards", + "webhooks" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@friggframework/core": "^2.0.0-next.107" + }, + "devDependencies": { + "@aws-sdk/client-scheduler": "^3.1113.0", + "dotenv": "^16.0.3", + "jest": "^28.1.3", + "js-yaml": "^4.1.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/v1-ready/trello/tests/api.test.js b/packages/v1-ready/trello/tests/api.test.js new file mode 100644 index 00000000..b48ab87c --- /dev/null +++ b/packages/v1-ready/trello/tests/api.test.js @@ -0,0 +1,279 @@ +const crypto = require('crypto'); +const { Api, SIGNATURE_HEADER, BASE_URL } = require('../api'); + +function makeApi(overrides = {}) { + const api = new Api({ + api_key: 'test-key', + token: 'test-token', + ...overrides, + }); + api._captured = []; + api._get = async (options) => { + api._captured.push({ method: 'GET', ...options }); + return {}; + }; + api._post = async (options) => { + api._captured.push({ method: 'POST', ...options }); + return { id: 'new' }; + }; + api._put = async (options) => { + api._captured.push({ method: 'PUT', ...options }); + return { id: 'upd' }; + }; + api._delete = async (options) => { + api._captured.push({ method: 'DELETE', ...options }); + return {}; + }; + return api; +} + +describe('Trello Api', () => { + describe('construction & auth', () => { + it('sets the base URL and credentials', () => { + const api = new Api({ api_key: 'k', token: 't' }); + expect(api.baseUrl).toBe(BASE_URL); + expect(api.api_key).toBe('k'); + expect(api.token).toBe('t'); + }); + + it('accepts access_token as an alias for token', () => { + const api = new Api({ api_key: 'k', access_token: 'tok' }); + expect(api.token).toBe('tok'); + }); + + it('injects key+token query params per official OpenAPI securitySchemes', () => { + const api = new Api({ api_key: 'akey', token: 'atok' }); + const withAuth = api._withAuth({ query: { fields: 'id' } }); + expect(withAuth.query).toEqual({ + fields: 'id', + key: 'akey', + token: 'atok', + }); + }); + + it('does not put credentials in Authorization headers', async () => { + const api = new Api({ api_key: 'akey', token: 'atok' }); + const headers = await api.addAuthHeaders({}); + expect(headers.Authorization).toBeUndefined(); + expect(headers.Accept).toBe('application/json'); + }); + + it('reports authenticated only with both key and token', () => { + expect( + new Api({ api_key: 'k', token: 't' }).isAuthenticated() + ).toBe(true); + expect(new Api({ api_key: 'k' }).isAuthenticated()).toBe(false); + expect(new Api({ token: 't' }).isAuthenticated()).toBe(false); + expect(new Api({}).isAuthenticated()).toBe(false); + }); + + it('exposes an apiKey authorization form', () => { + const api = new Api({ api_key: 'k' }); + const reqs = api.getAuthorizationRequirements(); + expect(reqs.type).toBe('apiKey'); + expect(reqs.data.jsonSchema.required).toEqual( + expect.arrayContaining(['api_key', 'token']) + ); + expect(reqs.url).toContain('trello.com/1/authorize'); + expect(reqs.url).toContain('key=k'); + }); + }); + + describe('members', () => { + it('getMember hits GET /members/{id}', async () => { + const api = makeApi(); + await api.getMember('me'); + expect(api._captured[0].url).toBe( + 'https://api.trello.com/1/members/me' + ); + }); + }); + + describe('boards', () => { + it('listBoards hits GET /members/{id}/boards', async () => { + const api = makeApi(); + await api.listBoards('me', { filter: 'open' }); + expect(api._captured[0].url).toBe( + 'https://api.trello.com/1/members/me/boards' + ); + expect(api._captured[0].query).toEqual({ filter: 'open' }); + }); + + it('getBoard / createBoard / updateBoard hit the board paths', async () => { + const api = makeApi(); + await api.getBoard('b1'); + await api.createBoard({ name: 'N' }); + await api.updateBoard('b1', { name: 'N2' }); + expect(api._captured[0].url).toBe( + 'https://api.trello.com/1/boards/b1' + ); + expect(api._captured[1].method).toBe('POST'); + expect(api._captured[1].url).toBe( + 'https://api.trello.com/1/boards/' + ); + expect(api._captured[1].query).toEqual({ name: 'N' }); + expect(api._captured[2].method).toBe('PUT'); + expect(api._captured[2].url).toBe( + 'https://api.trello.com/1/boards/b1' + ); + }); + }); + + describe('lists', () => { + it('listLists / getList / createList / updateList', async () => { + const api = makeApi(); + await api.listLists('b1'); + await api.getList('l1'); + await api.createList({ name: 'L', idBoard: 'b1' }); + await api.updateList('l1', { name: 'L2' }); + expect(api._captured[0].url).toBe( + 'https://api.trello.com/1/boards/b1/lists' + ); + expect(api._captured[1].url).toBe( + 'https://api.trello.com/1/lists/l1' + ); + expect(api._captured[2].method).toBe('POST'); + expect(api._captured[2].url).toBe( + 'https://api.trello.com/1/lists' + ); + expect(api._captured[3].method).toBe('PUT'); + }); + }); + + describe('cards', () => { + it('listCards / listListCards / getCard / createCard / updateCard', async () => { + const api = makeApi(); + await api.listCards('b1'); + await api.listListCards('l1'); + await api.getCard('c1'); + await api.createCard({ idList: 'l1', name: 'C' }); + await api.updateCard('c1', { name: 'C2' }); + expect(api._captured[0].url).toBe( + 'https://api.trello.com/1/boards/b1/cards' + ); + expect(api._captured[1].url).toBe( + 'https://api.trello.com/1/lists/l1/cards' + ); + expect(api._captured[2].url).toBe( + 'https://api.trello.com/1/cards/c1' + ); + expect(api._captured[3].method).toBe('POST'); + expect(api._captured[3].query).toEqual({ + idList: 'l1', + name: 'C', + }); + expect(api._captured[4].method).toBe('PUT'); + }); + }); + + describe('organizations & labels', () => { + it('listOrganizations / getOrganization / listLabels / createLabel', async () => { + const api = makeApi(); + await api.listOrganizations('me'); + await api.getOrganization('o1'); + await api.listLabels('b1'); + await api.createLabel({ idBoard: 'b1', name: 'bug', color: 'red' }); + expect(api._captured[0].url).toBe( + 'https://api.trello.com/1/members/me/organizations' + ); + expect(api._captured[1].url).toBe( + 'https://api.trello.com/1/organizations/o1' + ); + expect(api._captured[2].url).toBe( + 'https://api.trello.com/1/boards/b1/labels' + ); + expect(api._captured[3].method).toBe('POST'); + expect(api._captured[3].url).toBe( + 'https://api.trello.com/1/labels' + ); + }); + }); + + describe('webhooks', () => { + it('createWebhook POSTs query params to /webhooks/ (official swagger)', async () => { + const api = makeApi(); + const params = { + callbackURL: 'https://hook.example.com/trello', + idModel: 'model1', + description: 'test', + }; + await api.createWebhook(params); + expect(api._captured[0].method).toBe('POST'); + expect(api._captured[0].url).toBe( + 'https://api.trello.com/1/webhooks/' + ); + expect(api._captured[0].query).toEqual(params); + expect(api._captured[0].body).toBeUndefined(); + }); + + it('get / update / delete webhook', async () => { + const api = makeApi(); + await api.getWebhook('w1'); + await api.updateWebhook('w1', { active: false }); + await api.deleteWebhook('w1'); + expect(api._captured[0].url).toBe( + 'https://api.trello.com/1/webhooks/w1' + ); + expect(api._captured[1].method).toBe('PUT'); + expect(api._captured[1].query).toEqual({ active: false }); + expect(api._captured[2].method).toBe('DELETE'); + }); + + it('listTokenWebhooks / createTokenWebhook use the token path + query', async () => { + const api = makeApi(); + await api.listTokenWebhooks(); + const params = { + callbackURL: 'https://hook.example.com', + idModel: 'm1', + }; + await api.createTokenWebhook(params); + expect(api._captured[0].url).toBe( + 'https://api.trello.com/1/tokens/test-token/webhooks' + ); + expect(api._captured[1].method).toBe('POST'); + expect(api._captured[1].url).toBe( + 'https://api.trello.com/1/tokens/test-token/webhooks' + ); + expect(api._captured[1].query).toEqual(params); + expect(api._captured[1].body).toBeUndefined(); + }); + + it('verifyWebhookSignature accepts a valid HMAC-SHA1 digest', () => { + const secret = 'app-secret'; + const callbackURL = 'https://hook.example.com/trello'; + const body = { action: { type: 'updateCard' } }; + const raw = JSON.stringify(body); + const signature = crypto + .createHmac('sha1', secret) + .update(raw + callbackURL) + .digest('base64'); + + const api = new Api({ + api_key: 'k', + token: 't', + api_secret: secret, + }); + const result = api.verifyWebhookSignature({ + headers: { [SIGNATURE_HEADER]: signature }, + rawBody: raw, + callbackURL, + }); + expect(result).toEqual({ valid: true }); + }); + + it('verifyWebhookSignature rejects a bad signature', () => { + const api = new Api({ + api_key: 'k', + token: 't', + api_secret: 'secret', + }); + const result = api.verifyWebhookSignature({ + headers: { [SIGNATURE_HEADER]: 'nope' }, + rawBody: '{}', + callbackURL: 'https://hook.example.com', + }); + expect(result.valid).toBe(false); + expect(result.reason).toBe('signature mismatch'); + }); + }); +}); diff --git a/packages/v1-ready/trello/tests/definition.test.js b/packages/v1-ready/trello/tests/definition.test.js new file mode 100644 index 00000000..184dc0c0 --- /dev/null +++ b/packages/v1-ready/trello/tests/definition.test.js @@ -0,0 +1,103 @@ +const { Definition } = require('../definition'); +const { Api } = require('../api'); + +describe('Trello Definition', () => { + it('is an api-key module named trello wired to the Api class', () => { + expect(Definition.moduleName).toBe('trello'); + expect(Definition.getName()).toBe('trello'); + expect(Definition.API).toBe(Api); + expect(Definition.modelName).toBe('Trello'); + expect(Definition.moduleName).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/); + }); + + it('exposes an apiKey authorization form for api_key and token', () => { + const api = new Api({}); + const reqs = + Definition.requiredAuthMethods.getAuthorizationRequirements(api); + expect(reqs.type).toBe('apiKey'); + expect(reqs.data.jsonSchema.required).toEqual( + expect.arrayContaining(['api_key', 'token']) + ); + expect(reqs.data.uiSchema.token['ui:widget']).toBe('password'); + }); + + it('persists api_key and token credentials', () => { + expect(Definition.requiredAuthMethods.apiPropertiesToPersist).toEqual({ + credential: ['api_key', 'token'], + entity: [], + }); + }); + + it('setAuthParams sets key and token from form params', async () => { + const api = new Api({}); + await Definition.requiredAuthMethods.setAuthParams(api, { + api_key: 'form-key', + token: 'form-token', + }); + expect(api.api_key).toBe('form-key'); + expect(api.token).toBe('form-token'); + }); + + it('setAuthParams also reads nested params.data', async () => { + const api = new Api({}); + await Definition.requiredAuthMethods.setAuthParams(api, { + data: { api_key: 'nested-key', token: 'nested-token' }, + }); + expect(api.api_key).toBe('nested-key'); + expect(api.token).toBe('nested-token'); + }); + + it('getEntityDetails derives externalId from /members/me id', async () => { + const api = new Api({ api_key: 'k', token: 't' }); + api.getMember = async () => ({ + id: 'mem_abc', + fullName: 'Ada Lovelace', + username: 'ada', + }); + const details = await Definition.requiredAuthMethods.getEntityDetails( + api, + {}, + {}, + 'user-1' + ); + expect(details.identifiers.externalId).toBe('mem_abc'); + expect(details.identifiers.userId).toBe('user-1'); + expect(details.details.name).toBe('Ada Lovelace'); + expect(details.details.username).toBe('ada'); + }); + + it('getCredentialDetails uses the same member id', async () => { + const api = new Api({ api_key: 'k', token: 't' }); + api.getMember = async () => ({ + id: 'mem_xyz', + fullName: 'Grace', + username: 'grace', + }); + const details = + await Definition.requiredAuthMethods.getCredentialDetails( + api, + 'user-c' + ); + expect(details.identifiers.externalId).toBe('mem_xyz'); + expect(details.identifiers.userId).toBe('user-c'); + }); + + it('throws when /members/me has no id', async () => { + const api = new Api({ api_key: 'k', token: 't' }); + api.getMember = async () => ({ fullName: 'Anon' }); + await expect( + Definition.requiredAuthMethods.getEntityDetails(api, {}, {}, 'u') + ).rejects.toThrow(/stable account identity/i); + }); + + it('testAuthRequest calls getMember(me)', async () => { + const api = new Api({ api_key: 'k', token: 't' }); + let calledWith; + api.getMember = async (id) => { + calledWith = id; + return { id: 'm1' }; + }; + await Definition.requiredAuthMethods.testAuthRequest(api); + expect(calledWith).toBe('me'); + }); +}); diff --git a/packages/v1-ready/trello/tests/spec-sync.test.js b/packages/v1-ready/trello/tests/spec-sync.test.js new file mode 100644 index 00000000..767b8db2 --- /dev/null +++ b/packages/v1-ready/trello/tests/spec-sync.test.js @@ -0,0 +1,115 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const { Api } = require('../api'); + +const spec = yaml.load( + fs.readFileSync(path.join(__dirname, '..', 'trello.openapi.yaml'), 'utf8') +); + +const specOperationIds = Object.values(spec.paths).flatMap((item) => + Object.entries(item) + .filter(([m]) => ['get', 'post', 'patch', 'put', 'delete'].includes(m)) + .map(([, op]) => op.operationId) +); + +const clientMethods = Object.getOwnPropertyNames(Api.prototype).filter( + (m) => typeof Api.prototype[m] === 'function' && m !== 'constructor' +); + +const capabilities = JSON.parse( + fs.readFileSync( + path.join(__dirname, '..', 'trello.capabilities.json'), + 'utf8' + ) +); + +describe('OpenAPI spec ↔ client sync', () => { + it('every operationId has a matching client method', () => { + const missing = specOperationIds.filter( + (op) => !clientMethods.includes(op) + ); + expect(missing).toEqual([]); + }); + + it('the base server URL matches the client baseUrl', () => { + const api = new Api({ api_key: 'x', token: 'y' }); + expect(spec.servers[0].url).toBe(api.baseUrl); + }); + + it('declares official APIKey+APIToken query security schemes', () => { + const key = spec.components.securitySchemes.APIKey; + const token = spec.components.securitySchemes.APIToken; + expect(key).toEqual( + expect.objectContaining({ + type: 'apiKey', + in: 'query', + name: 'key', + }) + ); + expect(token).toEqual( + expect.objectContaining({ + type: 'apiKey', + in: 'query', + name: 'token', + }) + ); + expect(spec.security).toEqual([{ APIKey: [], APIToken: [] }]); + // Client injects the same query names the OpenAPI securitySchemes declare. + const api = new Api({ api_key: 'k', token: 't' }); + expect(api._withAuth({}).query).toEqual({ key: 'k', token: 't' }); + }); + + it('webhook create/update params are query (not requestBody) per vendor swagger', () => { + const create = spec.paths['/webhooks/'].post; + const update = spec.paths['/webhooks/{id}'].put; + expect(create.requestBody).toBeUndefined(); + expect(update.requestBody).toBeUndefined(); + const createNames = create.parameters.map((p) => p.name).sort(); + expect(createNames).toEqual( + ['active', 'callbackURL', 'description', 'idModel'].sort() + ); + expect( + create.parameters.find((p) => p.name === 'callbackURL').required + ).toBe(true); + expect( + create.parameters.find((p) => p.name === 'idModel').required + ).toBe(true); + }); + + it('createLabel requires name, color, and idBoard per vendor swagger', () => { + const params = spec.paths['/labels'].post.parameters; + for (const name of ['name', 'color', 'idBoard']) { + expect(params.find((p) => p.name === name).required).toBe(true); + } + }); +}); + +describe('Capability manifest ↔ client sync', () => { + it('is a capability-manifest/v0.1 for this package', () => { + expect(capabilities.$schema).toContain('capability-manifest/v0.1'); + expect(capabilities.module).toBe('trello'); + expect(capabilities.package).toBe( + '@friggframework/api-module-trello' + ); + expect(capabilities.version).toBe('1.0.0'); + expect(capabilities.auth).toBe('apiKey'); + }); + + it('every capability method maps to a real Api method', () => { + const missing = Object.entries(capabilities.capabilities) + .filter(([, cap]) => !clientMethods.includes(cap.method)) + .map(([name, cap]) => `${name} → ${cap.method}`); + expect(missing).toEqual([]); + }); + + it('objects declare fields with tags where applicable', () => { + expect(capabilities.objects.card.fields.length).toBeGreaterThan(0); + expect( + capabilities.objects.card.fields.some((f) => f.tag) + ).toBe(true); + expect(capabilities.objects.board.fields.some((f) => f.tag)).toBe( + true + ); + }); +}); diff --git a/packages/v1-ready/trello/trello.capabilities.json b/packages/v1-ready/trello/trello.capabilities.json new file mode 100644 index 00000000..65987e20 --- /dev/null +++ b/packages/v1-ready/trello/trello.capabilities.json @@ -0,0 +1,874 @@ +{ + "$schema": "https://friggframework.org/schemas/capability-manifest/v0.1.json", + "module": "trello", + "label": "Trello", + "package": "@friggframework/api-module-trello", + "version": "1.0.0", + "auth": "apiKey", + "account": { + "description": "Member identity from GET /members/me. Persisted as entity/credential externalId.", + "fields": [ + { + "name": "id", + "type": "string", + "description": "Trello member id from /members/me" + }, + { + "name": "username", + "type": "string", + "description": "Trello username" + } + ] + }, + "objects": { + "member": { + "label": "Member", + "fields": [ + { + "name": "id", + "type": "id", + "readOnly": true, + "tag": "person.id" + }, + { + "name": "fullName", + "type": "string", + "tag": "person.displayName" + }, + { + "name": "username", + "type": "string" + }, + { + "name": "email", + "type": "email", + "tag": "person.email" + }, + { + "name": "url", + "type": "string", + "tag": "record.url", + "readOnly": true + }, + { + "name": "avatarUrl", + "type": "string", + "readOnly": true + }, + { + "name": "initials", + "type": "string", + "readOnly": true + } + ] + }, + "board": { + "label": "Board", + "fields": [ + { + "name": "id", + "type": "id", + "readOnly": true, + "tag": "record.id" + }, + { + "name": "name", + "type": "string", + "tag": "record.displayName" + }, + { + "name": "desc", + "type": "text" + }, + { + "name": "closed", + "type": "boolean" + }, + { + "name": "idOrganization", + "type": "string" + }, + { + "name": "url", + "type": "string", + "tag": "record.url", + "readOnly": true + }, + { + "name": "shortUrl", + "type": "string", + "readOnly": true + }, + { + "name": "dateLastActivity", + "type": "datetime", + "tag": "record.updatedAt", + "readOnly": true + } + ], + "sync": { + "id": "id", + "updatedAt": "dateLastActivity", + "list": { + "capability": "board.list", + "items": "$", + "paging": "none" + }, + "get": { + "capability": "board.get", + "id": "boardId" + }, + "create": { + "capability": "board.create", + "body": "$rest" + }, + "update": { + "capability": "board.update", + "id": "boardId", + "body": "$rest" + } + } + }, + "list": { + "label": "List", + "fields": [ + { + "name": "id", + "type": "id", + "readOnly": true, + "tag": "record.id" + }, + { + "name": "name", + "type": "string", + "tag": "record.displayName" + }, + { + "name": "closed", + "type": "boolean" + }, + { + "name": "idBoard", + "type": "string" + }, + { + "name": "pos", + "type": "number" + }, + { + "name": "subscribed", + "type": "boolean" + } + ], + "sync": { + "id": "id", + "list": { + "capability": "list.list", + "items": "$", + "paging": "none" + }, + "get": { + "capability": "list.get", + "id": "listId" + }, + "create": { + "capability": "list.create", + "body": "$rest" + }, + "update": { + "capability": "list.update", + "id": "listId", + "body": "$rest" + } + } + }, + "card": { + "label": "Card", + "fields": [ + { + "name": "id", + "type": "id", + "readOnly": true, + "tag": "record.id" + }, + { + "name": "name", + "type": "string", + "tag": "record.displayName" + }, + { + "name": "desc", + "type": "text" + }, + { + "name": "closed", + "type": "boolean" + }, + { + "name": "idBoard", + "type": "string" + }, + { + "name": "idList", + "type": "string" + }, + { + "name": "idMembers", + "type": "string[]", + "tag": "person.id" + }, + { + "name": "idLabels", + "type": "string[]" + }, + { + "name": "url", + "type": "string", + "tag": "record.url", + "readOnly": true + }, + { + "name": "due", + "type": "datetime" + }, + { + "name": "dueComplete", + "type": "boolean" + }, + { + "name": "dateLastActivity", + "type": "datetime", + "tag": "record.updatedAt", + "readOnly": true + }, + { + "name": "pos", + "type": "number" + }, + { + "name": "shortLink", + "type": "string", + "readOnly": true + } + ], + "sync": { + "id": "id", + "updatedAt": "dateLastActivity", + "list": { + "capability": "card.list", + "items": "$", + "paging": "none" + }, + "get": { + "capability": "card.get", + "id": "cardId" + }, + "create": { + "capability": "card.create", + "body": "$rest" + }, + "update": { + "capability": "card.update", + "id": "cardId", + "body": "$rest" + } + } + }, + "organization": { + "label": "Organization (Workspace)", + "fields": [ + { + "name": "id", + "type": "id", + "readOnly": true, + "tag": "record.id" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "displayName", + "type": "string", + "tag": "record.displayName" + }, + { + "name": "url", + "type": "string", + "tag": "record.url", + "readOnly": true + }, + { + "name": "idBoards", + "type": "string[]", + "readOnly": true + }, + { + "name": "idEnterprise", + "type": "string", + "readOnly": true + }, + { + "name": "dateLastActivity", + "type": "datetime", + "tag": "record.updatedAt", + "readOnly": true + }, + { + "name": "offering", + "type": "string", + "readOnly": true + } + ] + }, + "label": { + "label": "Label", + "fields": [ + { + "name": "id", + "type": "id", + "readOnly": true, + "tag": "record.id" + }, + { + "name": "name", + "type": "string", + "tag": "record.displayName" + }, + { + "name": "color", + "type": "string" + }, + { + "name": "idBoard", + "type": "string" + } + ] + }, + "webhook": { + "label": "Webhook", + "fields": [ + { + "name": "id", + "type": "id", + "readOnly": true + }, + { + "name": "description", + "type": "string" + }, + { + "name": "idModel", + "type": "string" + }, + { + "name": "callbackURL", + "type": "string" + }, + { + "name": "active", + "type": "boolean" + }, + { + "name": "consecutiveFailures", + "type": "number", + "readOnly": true + }, + { + "name": "firstConsecutiveFailDate", + "type": "datetime", + "readOnly": true + } + ] + } + }, + "events": { + "trello.action": { + "delivery": "webhook", + "object": "card", + "description": "Any Action on a watched model (board/list/card/member/organization). Payload carries action, model, webhook.", + "payload": { + "action": { + "id": "51f9424bcd6e040f3c002412", + "type": "updateCard", + "date": "2013-07-31T16:58:51.949Z", + "idMemberCreator": "4fc78a59a885233f4b349bd9", + "data": {} + }, + "model": { + "id": "4d5ea62fd76aa1136000000c", + "name": "Example Board" + }, + "webhook": { + "id": "5f5ea62fd76aa1136003300c", + "idModel": "4d5ea62fd76aa1136000000c", + "callbackURL": "https://example.com/webhooks/trello?memberId=4fc78a59a885233f4b349bd9", + "active": true + } + }, + "record": "model" + } + }, + "webhook": { + "scope": "token", + "topic": "{{body.action.type}}", + "eventId": "{{body.action.id}}", + "account": "{{body.webhook.callbackURL}}", + "verify": "verifyWebhookSignature" + }, + "capabilities": { + "member.get": { + "kind": "read", + "method": "getMember", + "input": { + "id": { + "type": "string", + "required": true + } + }, + "output": "member", + "args": [ + "id", + "$rest" + ] + }, + "board.list": { + "kind": "read", + "method": "listBoards", + "input": { + "memberId": { + "type": "string" + }, + "filter": { + "type": "string" + }, + "fields": { + "type": "string" + } + }, + "output": "board[]", + "args": [ + "memberId", + "$rest" + ] + }, + "board.get": { + "kind": "read", + "method": "getBoard", + "input": { + "boardId": { + "type": "string", + "required": true + } + }, + "output": "board", + "args": [ + "boardId", + "$rest" + ] + }, + "board.create": { + "kind": "write", + "method": "createBoard", + "input": { + "name": { + "type": "string", + "required": true + }, + "desc": { + "type": "string" + }, + "idOrganization": { + "type": "string" + } + }, + "output": "board", + "args": [ + "$rest" + ] + }, + "board.update": { + "kind": "write", + "method": "updateBoard", + "input": { + "boardId": { + "type": "string", + "required": true + }, + "name": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "closed": { + "type": "boolean" + } + }, + "output": "board", + "args": [ + "boardId", + "$rest" + ] + }, + "list.list": { + "kind": "read", + "method": "listLists", + "input": { + "boardId": { + "type": "string", + "required": true + } + }, + "output": "list[]", + "args": [ + "boardId", + "$rest" + ] + }, + "list.get": { + "kind": "read", + "method": "getList", + "input": { + "listId": { + "type": "string", + "required": true + } + }, + "output": "list", + "args": [ + "listId", + "$rest" + ] + }, + "list.create": { + "kind": "write", + "method": "createList", + "input": { + "name": { + "type": "string", + "required": true + }, + "idBoard": { + "type": "string", + "required": true + }, + "pos": { + "type": "string" + } + }, + "output": "list", + "args": [ + "$rest" + ] + }, + "list.update": { + "kind": "write", + "method": "updateList", + "input": { + "listId": { + "type": "string", + "required": true + }, + "name": { + "type": "string" + }, + "closed": { + "type": "boolean" + }, + "pos": { + "type": "string" + } + }, + "output": "list", + "args": [ + "listId", + "$rest" + ] + }, + "card.list": { + "kind": "read", + "method": "listCards", + "input": { + "boardId": { + "type": "string", + "required": true + } + }, + "output": "card[]", + "args": [ + "boardId", + "$rest" + ] + }, + "card.listByList": { + "kind": "read", + "method": "listListCards", + "input": { + "listId": { + "type": "string", + "required": true + } + }, + "output": "card[]", + "args": [ + "listId", + "$rest" + ] + }, + "card.get": { + "kind": "read", + "method": "getCard", + "input": { + "cardId": { + "type": "string", + "required": true + } + }, + "output": "card", + "args": [ + "cardId", + "$rest" + ] + }, + "card.create": { + "kind": "write", + "method": "createCard", + "input": { + "idList": { + "type": "string", + "required": true + }, + "name": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "due": { + "type": "string" + } + }, + "output": "card", + "args": [ + "$rest" + ] + }, + "card.update": { + "kind": "write", + "method": "updateCard", + "input": { + "cardId": { + "type": "string", + "required": true + }, + "name": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "idList": { + "type": "string" + }, + "closed": { + "type": "boolean" + } + }, + "output": "card", + "args": [ + "cardId", + "$rest" + ] + }, + "organization.list": { + "kind": "read", + "method": "listOrganizations", + "input": { + "memberId": { + "type": "string" + } + }, + "output": "organization[]", + "args": [ + "memberId", + "$rest" + ] + }, + "organization.get": { + "kind": "read", + "method": "getOrganization", + "input": { + "organizationId": { + "type": "string", + "required": true + } + }, + "output": "organization", + "args": [ + "organizationId", + "$rest" + ] + }, + "label.list": { + "kind": "read", + "method": "listLabels", + "input": { + "boardId": { + "type": "string", + "required": true + } + }, + "output": "label[]", + "args": [ + "boardId", + "$rest" + ] + }, + "label.create": { + "kind": "write", + "method": "createLabel", + "input": { + "idBoard": { + "type": "string", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "color": { + "type": "string", + "required": true + } + }, + "output": "label", + "args": [ + "$rest" + ] + }, + "webhook.create": { + "kind": "write", + "method": "createWebhook", + "input": { + "callbackURL": { + "type": "string", + "required": true + }, + "idModel": { + "type": "string", + "required": true + }, + "description": { + "type": "string" + } + }, + "output": "webhook", + "args": [ + "$rest" + ] + }, + "webhook.get": { + "kind": "read", + "method": "getWebhook", + "input": { + "webhookId": { + "type": "string", + "required": true + } + }, + "output": "webhook", + "args": [ + "webhookId" + ] + }, + "webhook.update": { + "kind": "write", + "method": "updateWebhook", + "input": { + "webhookId": { + "type": "string", + "required": true + }, + "callbackURL": { + "type": "string" + }, + "idModel": { + "type": "string" + }, + "active": { + "type": "boolean" + } + }, + "output": "webhook", + "args": [ + "webhookId", + "$rest" + ] + }, + "webhook.delete": { + "kind": "write", + "method": "deleteWebhook", + "input": { + "webhookId": { + "type": "string", + "required": true + } + }, + "output": "webhook", + "args": [ + "webhookId" + ] + }, + "webhook.listByToken": { + "kind": "read", + "method": "listTokenWebhooks", + "input": { + "token": { + "type": "string" + } + }, + "output": "webhook[]", + "args": [ + "token" + ] + }, + "webhook.createForToken": { + "kind": "write", + "method": "createTokenWebhook", + "input": { + "callbackURL": { + "type": "string", + "required": true + }, + "idModel": { + "type": "string", + "required": true + }, + "description": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "output": "webhook", + "args": [ + "$rest", + "token" + ] + } + }, + "options": { + "board": { + "capability": "board.list", + "label": "{{item.name}}", + "value": "$.item.id" + }, + "organization": { + "capability": "organization.list", + "label": "{{item.displayName}}", + "value": "$.item.id" + } + } +} diff --git a/packages/v1-ready/trello/trello.fenestra.yaml b/packages/v1-ready/trello/trello.fenestra.yaml new file mode 100644 index 00000000..b3a357b3 --- /dev/null +++ b/packages/v1-ready/trello/trello.fenestra.yaml @@ -0,0 +1,105 @@ +# Fenestra Document: Trello Power-Ups +# +# Trello Power-Ups are iframe-hosted extensions that run inside the Trello UI. +# The connector page loads power-up.js and registers capability handlers via +# `window.TrelloPowerUp.initialize({ ... })`. Secondary pages (popups, sections) +# use `window.TrelloPowerUp.iframe()`. +# +# Docs: https://developer.atlassian.com/cloud/trello/power-ups/ +# Client library: https://developer.atlassian.com/cloud/trello/power-ups/client-library/ +# +# Drafted against Fenestra 0.1.0 +# (api-module-library branch claude/fenestra-spec-draft-Q367t). +# Trello is listed under Productivity & Collaboration in +# specs/fenestra/PLATFORMS.md (Iframe / Power-Ups). +# +# The `@friggframework/api-module-trello` package implements the REST API +# (key+token auth, boards/lists/cards/webhooks), not the browser Power-Up SDK. + +fenestra: "0.1.0" + +info: + title: Trello Power-Ups + version: "1.0.0" + description: | + Trello Power-Ups extend the Trello board and card UI via an HTTPS iframe + connector. Capabilities are declared in the Power-Up admin portal and + implemented as handlers on `TrelloPowerUp.initialize`. + + Common capabilities (non-exhaustive; see Power-Ups reference): + - board-buttons — buttons on the board header + - card-buttons — buttons on the card back + - card-badges / card-detail-badges — badges on cards + - attachment-sections — rich iframes for claimed attachment URLs + - attachment-thumbnail — custom attachment previews + - authorization-status / show-authorization — OAuth / authorize UX + - on-enable / on-disable — lifecycle hooks + +platform: + name: Trello + vendor: Atlassian + documentation: https://developer.atlassian.com/cloud/trello/power-ups/ + appMarketplace: https://trello.com/app-marketplace + developerPortal: https://trello.com/apps/admin + +sdks: + power-up-client: + name: Trello Power-Up Client Library + packageManager: npm + package: power-up.min.js + platform: web + documentation: https://developer.atlassian.com/cloud/trello/power-ups/client-library/ + description: | + Distributed as a CDN browser script (`https://p.trellocdn.com/power-up.min.js`), + not an npm package — `packageManager: npm` is used only to satisfy the + Fenestra 0.1.0 enum. Exposes `window.TrelloPowerUp`. Use `.initialize` + once on the connector page and `.iframe` on secondary iframe pages. + Capability handlers return button/section descriptors; `t.signUrl` + is required for some iframe content URLs (attachment-sections, + attachment-thumbnail initialize). + exports: + TrelloPowerUp: + type: utility + description: Global Power-Up client (`initialize`, `iframe`, `util`). + initialize: + type: hook + description: Register capability handlers on the connector page. + iframe: + type: hook + description: Bind a secondary iframe page to the Power-Up context (`t`). + +extensionPoints: + - id: board-buttons + name: Board buttons + type: toolbar + extensionType: iframe + description: | + Buttons rendered on the board header. Handler returns an array of + `{ icon, text, callback | url | condition }` descriptors. + documentation: https://developer.atlassian.com/cloud/trello/power-ups/capabilities/board-buttons/ + supportedComponents: + - all + + - id: card-buttons + name: Card buttons + type: sidebar + extensionType: iframe + description: | + Buttons on the card back. Typically open a popup (`t.popup`) or + navigate to a signed URL. + documentation: https://developer.atlassian.com/cloud/trello/power-ups/capabilities/card-buttons/ + supportedComponents: + - all + + - id: attachment-sections + name: Attachment sections + type: embedded + extensionType: iframe + description: | + Claim card attachment URLs and render an iframe section above the + attachments list. Content URL must be signed with `t.signUrl`. + documentation: https://developer.atlassian.com/cloud/trello/power-ups/capabilities/attachment-sections/ + supportedComponents: + - all + constraints: + httpsOnly: true diff --git a/packages/v1-ready/trello/trello.openapi.yaml b/packages/v1-ready/trello/trello.openapi.yaml new file mode 100644 index 00000000..2c35a055 --- /dev/null +++ b/packages/v1-ready/trello/trello.openapi.yaml @@ -0,0 +1,2263 @@ +openapi: 3.0.0 +info: + title: Trello REST API (subset) + version: 1.0.0 + description: Left Hook curated subset of the official Trello REST OpenAPI (https://developer.atlassian.com/cloud/trello/swagger.v3.json) + covering exactly the endpoints implemented by @friggframework/api-module-trello. Parameters and schemas + are taken from that vendor document. One method per operationId — keep in sync with api.js. Global + security matches the vendor document (APIKey + APIToken query params). Operation-level OAuth2-only + security entries in the full vendor swagger are omitted here because the Authorization and Webhooks + guides document classic key+token for these same routes. + contact: + name: Left Hook + url: https://lefthook.com +servers: +- url: https://api.trello.com/1 +security: +- APIKey: [] + APIToken: [] +tags: +- name: Members +- name: Boards +- name: Lists +- name: Cards +- name: Organizations +- name: Labels +- name: Webhooks +paths: + /members/{id}: + get: + tags: + - Members + operationId: getMember + summary: Get a Member + description: 'Official operationId: get-members=id.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Member' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + description: The ID or username of the member + schema: + oneOf: + - type: string + pattern: ^[0-9a-fA-F]{24}$ + - type: string + - name: actions + in: query + description: See the [Actions Nested Resource](/cloud/trello/guides/rest-api/nested-resources/#actions-nested-resource) + schema: + type: string + - name: boards + in: query + description: See the [Boards Nested Resource](/cloud/trello/guides/rest-api/nested-resources/#boards-nested-resource) + schema: + type: string + - name: boardBackgrounds + in: query + description: 'One of: `all`, `custom`, `default`, `none`, `premium`' + schema: + type: string + enum: + - all + - custom + - default + - none + - premium + default: none + - name: boardsInvited + in: query + description: '`all` or a comma-separated list of: closed, members, open, organization, pinned, + public, starred, unpinned' + schema: + enum: + - closed + - members + - open + - organization + - pinned + - public + - starred + - unpinned + type: string + - name: boardsInvited_fields + in: query + description: '`all` or a comma-separated list of board [fields](/cloud/trello/guides/rest-api/object-definitions/)' + schema: + type: string + enum: &id002 + - id + - name + - desc + - descData + - closed + - idMemberCreator + - idOrganization + - pinned + - url + - shortUrl + - prefs + - labelNames + - starred + - limits + - memberships + - enterpriseOwned + - name: boardStars + in: query + description: Whether to return the boardStars or not + schema: + type: boolean + default: false + - name: cards + in: query + description: See the [Cards Nested Resource](/cloud/trello/guides/rest-api/nested-resources/#cards-nested-resource) + for additional options + schema: + type: string + default: none + - name: customBoardBackgrounds + in: query + description: '`all` or `none`' + schema: + type: string + enum: + - all + - none + default: none + - name: customEmoji + in: query + description: '`all` or `none`' + schema: + type: string + enum: + - all + - none + default: none + - name: customStickers + in: query + description: '`all` or `none`' + schema: + type: string + enum: + - all + - none + default: none + - name: fields + in: query + description: '`all` or a comma-separated list of member [fields](/cloud/trello/guides/rest-api/object-definitions/)' + schema: + type: string + enum: + - id + - name: notifications + in: query + description: See the [Notifications Nested Resource](/cloud/trello/guides/rest-api/nested-resources/#notifications-nested-resource) + schema: + type: string + - name: organizations + in: query + description: 'One of: `all`, `members`, `none`, `public`' + schema: + type: string + enum: + - all + - members + - none + - public + default: none + - name: organization_fields + in: query + description: '`all` or a comma-separated list of organization [fields](/cloud/trello/guides/rest-api/object-definitions/)' + schema: + type: string + enum: &id001 + - id + - name + - name: organization_paid_account + in: query + description: Whether or not to include paid account information in the returned workspace object + schema: + type: boolean + default: false + - name: organizationsInvited + in: query + description: 'One of: `all`, `members`, `none`, `public`' + schema: + type: string + enum: + - all + - members + - none + - public + default: none + - name: organizationsInvited_fields + in: query + description: '`all` or a comma-separated list of organization [fields](/cloud/trello/guides/rest-api/object-definitions/)' + schema: + type: string + enum: *id001 + - name: paid_account + in: query + description: Whether or not to include paid account information in the returned member object + schema: + type: boolean + default: false + - name: savedSearches + in: query + schema: + type: boolean + default: false + - name: tokens + in: query + description: '`all` or `none`' + schema: + type: string + enum: + - all + - none + default: none + /members/{id}/boards: + get: + tags: + - Members + operationId: listBoards + summary: Get Boards that Member belongs to + description: 'Official operationId: get-members-id-boards.' + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Board' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + description: The ID or username of the member + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: filter + in: query + description: '`all` or a comma-separated list of: `closed`, `members`, `open`, `organization`, + `public`, `starred`' + schema: + enum: + - all + - closed + - members + - open + - organization + - public + - starred + type: string + default: all + - name: fields + in: query + description: '`all` or a comma-separated list of board [fields](/cloud/trello/guides/rest-api/object-definitions/)' + schema: + type: string + enum: *id002 + - name: lists + in: query + description: 'Which lists to include with the boards. One of: `all`, `closed`, `none`, `open`' + schema: + enum: + - all + - closed + - none + - open + type: string + default: none + - name: organization + in: query + description: Whether to include the Organization object with the Boards + schema: + type: boolean + default: false + - name: organization_fields + in: query + description: '`all` or a comma-separated list of organization [fields](/cloud/trello/guides/rest-api/object-definitions/)' + schema: + type: string + enum: *id001 + /boards/{id}: + get: + tags: + - Boards + operationId: getBoard + summary: Get a Board + description: 'Official operationId: get-boards-id.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Board' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: actions + in: query + description: This is a nested resource. Read more about actions as nested resources [here](/cloud/trello/guides/rest-api/nested-resources/). + schema: + type: string + default: all + - name: boardStars + in: query + description: 'Valid values are one of: `mine` or `none`.' + schema: + type: string + default: none + - name: cards + in: query + description: This is a nested resource. Read more about cards as nested resources [here](/cloud/trello/guides/rest-api/nested-resources/). + schema: + type: string + default: none + - name: card_pluginData + in: query + description: Use with the `cards` param to include card pluginData with the response + schema: + type: boolean + default: false + - name: checklists + in: query + description: This is a nested resource. Read more about checklists as nested resources [here](/cloud/trello/guides/rest-api/nested-resources/). + schema: + type: string + default: none + - name: customFields + in: query + description: This is a nested resource. Read more about custom fields as nested resources [here](#custom-fields-nested-resource). + schema: + type: boolean + default: false + - name: fields + in: query + description: 'The fields of the board to be included in the response. Valid values: all or a comma-separated + list of: closed, dateLastActivity, dateLastView, desc, descData, idMemberCreator, idOrganization, + invitations, invited, labelNames, memberships, name, pinned, powerUps, prefs, shortLink, shortUrl, + starred, subscribed, url' + schema: + type: string + default: name,desc,descData,closed,idOrganization,pinned,url,shortUrl,prefs,labelNames + - name: labels + in: query + description: This is a nested resource. Read more about labels as nested resources [here](/cloud/trello/guides/rest-api/nested-resources/). + schema: + type: string + - name: lists + in: query + description: This is a nested resource. Read more about lists as nested resources [here](/cloud/trello/guides/rest-api/nested-resources/). + schema: + type: string + default: open + - name: members + in: query + description: This is a nested resource. Read more about members as nested resources [here](/cloud/trello/guides/rest-api/nested-resources/). + schema: + type: string + default: none + - name: memberships + in: query + description: This is a nested resource. Read more about memberships as nested resources [here](/cloud/trello/guides/rest-api/nested-resources/). + schema: + type: string + default: none + - name: pluginData + in: query + description: 'Determines whether the pluginData for this board should be returned. Valid values: + true or false.' + schema: + type: boolean + default: false + - name: organization + in: query + description: This is a nested resource. Read more about organizations as nested resources [here](/cloud/trello/guides/rest-api/nested-resources/). + schema: + type: boolean + default: false + - name: organization_pluginData + in: query + description: Use with the `organization` param to include organization pluginData with the response + schema: + type: boolean + default: false + - name: myPrefs + in: query + schema: + type: boolean + default: false + - name: tags + in: query + description: Also known as collections, tags, refer to the collection(s) that a Board belongs + to. + schema: + type: boolean + default: false + put: + tags: + - Boards + operationId: updateBoard + summary: Update a Board + description: 'Official operationId: put-boards-id.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Board' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: name + in: query + description: The new name for the board. 1 to 16384 characters long. + schema: + type: string + - name: desc + in: query + description: A new description for the board, 0 to 16384 characters long + schema: + type: string + - name: closed + in: query + description: Whether the board is closed + schema: + type: boolean + - name: subscribed + in: query + description: Whether the acting user is subscribed to the board + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: idOrganization + in: query + description: The id of the Workspace the board should be moved to + schema: + type: string + - name: prefs/permissionLevel + in: query + description: 'One of: org, private, public' + schema: + type: string + - name: prefs/selfJoin + in: query + description: Whether Workspace members can join the board themselves + schema: + type: boolean + - name: prefs/cardCovers + in: query + description: Whether card covers should be displayed on this board + schema: + type: boolean + - name: prefs/hideVotes + in: query + description: Determines whether the Voting Power-Up should hide who voted on cards or not. + schema: + type: boolean + - name: prefs/invitations + in: query + description: 'Who can invite people to this board. One of: admins, members' + schema: + type: string + - name: prefs/voting + in: query + description: Who can vote on this board. One of disabled, members, observers, org, public + schema: + type: string + - name: prefs/comments + in: query + description: 'Who can comment on cards on this board. One of: disabled, members, observers, org, + public' + schema: + type: string + - name: prefs/background + in: query + description: 'The id of a custom background or one of: blue, orange, green, red, purple, pink, + lime, sky, grey' + schema: + type: string + - name: prefs/cardAging + in: query + description: 'One of: pirate, regular' + schema: + type: string + - name: prefs/calendarFeedEnabled + in: query + description: Determines whether the calendar feed is enabled or not. + schema: + type: boolean + /boards/: + post: + tags: + - Boards + operationId: createBoard + summary: Create a Board + description: 'Official operationId: post-boards.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Board' + '401': + description: Unauthorized + parameters: + - name: name + in: query + required: true + description: The new name for the board. 1 to 16384 characters long. + schema: + type: string + maxLength: 16384 + minLength: 1 + - name: defaultLabels + in: query + description: Determines whether to use the default set of labels. + schema: + type: boolean + default: true + - name: defaultLists + in: query + description: Determines whether to add the default set of lists to a board (To Do, Doing, Done). + It is ignored if `idBoardSource` is provided. + schema: + type: boolean + default: true + - name: desc + in: query + description: A new description for the board, 0 to 16384 characters long + schema: + type: string + minLength: 0 + maxLength: 16384 + - name: idOrganization + in: query + description: The id or name of the Workspace the board should belong to. + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: idBoardSource + in: query + description: The id of a board to copy into the new board. + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: keepFromSource + in: query + description: To keep cards from the original board pass in the value `cards` + schema: + type: string + default: none + enum: + - cards + - none + - name: powerUps + in: query + description: 'The Power-Ups that should be enabled on the new board. One of: `all`, `calendar`, + `cardAging`, `recap`, `voting`.' + schema: + type: string + enum: + - all + - calendar + - cardAging + - recap + - voting + - name: prefs_permissionLevel + in: query + description: 'The permissions level of the board. One of: `org`, `private`, `public`.' + schema: + type: string + default: private + enum: + - org + - private + - public + - name: prefs_voting + in: query + description: Who can vote on this board. One of `disabled`, `members`, `observers`, `org`, `public`. + schema: + type: string + default: disabled + enum: + - disabled + - members + - observers + - org + - public + - name: prefs_comments + in: query + description: 'Who can comment on cards on this board. One of: `disabled`, `members`, `observers`, + `org`, `public`.' + schema: + type: string + default: members + enum: + - disabled + - members + - observers + - org + - public + - name: prefs_invitations + in: query + description: 'Determines what types of members can invite users to join. One of: `admins`, `members`.' + schema: + type: string + default: members + enum: + - members + - admins + - name: prefs_selfJoin + in: query + description: Determines whether users can join the boards themselves or whether they have to be + invited. + schema: + type: boolean + default: true + - name: prefs_cardCovers + in: query + description: Determines whether card covers are enabled. + schema: + type: boolean + default: true + - name: prefs_background + in: query + description: 'The id of a custom background or one of: `blue`, `orange`, `green`, `red`, `purple`, + `pink`, `lime`, `sky`, `grey`.' + schema: + type: string + default: blue + enum: + - blue + - orange + - green + - red + - purple + - pink + - lime + - sky + - grey + - name: prefs_cardAging + in: query + description: 'Determines the type of card aging that should take place on the board if card aging + is enabled. One of: `pirate`, `regular`.' + schema: + type: string + default: regular + enum: + - pirate + - regular + /boards/{id}/lists: + get: + tags: + - Boards + operationId: listLists + summary: Get Lists on a Board + description: 'Official operationId: get-boards-id-lists.' + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TrelloList' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: cards + in: query + description: Filter to apply to Cards. + schema: + type: string + enum: &id003 + - all + - closed + - none + - open + - name: card_fields + in: query + description: '`all` or a comma-separated list of card [fields](/cloud/trello/guides/rest-api/object-definitions/#card-object)' + schema: + type: string + default: all + - name: filter + in: query + description: Filter to apply to Lists + schema: + type: string + enum: *id003 + - name: fields + in: query + description: '`all` or a comma-separated list of list [fields](/cloud/trello/guides/rest-api/object-definitions/)' + schema: + type: string + default: all + /boards/{id}/cards: + get: + tags: + - Boards + operationId: listCards + summary: Get Cards on a Board + description: 'Official operationId: get-boards-id-cards.' + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Card' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + schema: + type: string + /boards/{id}/labels: + get: + tags: + - Boards + operationId: listLabels + summary: Get Labels on a Board + description: 'Official operationId: get-boards-id-labels.' + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Label' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + description: The ID of the Board. + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: fields + in: query + description: The fields to be returned for the Labels. + schema: + type: object + properties: + id: + type: string + pattern: ^[0-9a-fA-F]{24}$ + idBoard: + type: string + pattern: ^[0-9a-fA-F]{24}$ + name: + type: string + description: The name displayed for the label. + nullable: true + minLength: 0 + maxLength: 16384 + color: + type: string + nullable: true + enum: + - yellow + - purple + - blue + - red + - green + - orange + - black + - sky + - pink + - lime + - name: limit + in: query + description: The number of Labels to be returned. + schema: + type: integer + format: int32 + default: 50 + minimum: 0 + maximum: 1000 + /lists: + post: + tags: + - Lists + operationId: createList + summary: Create a new List + description: 'Official operationId: post-lists.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/TrelloList' + '401': + description: Unauthorized + parameters: + - name: name + in: query + required: true + description: Name for the list + schema: + type: string + - name: idBoard + in: query + required: true + description: The long ID of the board the list should be created on + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: idListSource + in: query + description: ID of the List to copy into the new List + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: pos + in: query + description: Position of the list. `top`, `bottom`, or a positive floating point number + schema: + oneOf: + - type: number + format: float + - type: string + enum: + - top + - bottom + /lists/{id}: + get: + tags: + - Lists + operationId: getList + summary: Get a List + description: 'Official operationId: get-lists-id.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/TrelloList' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: fields + in: query + description: '`all` or a comma separated list of List field names.' + schema: + type: string + default: name,closed,idBoard,pos + put: + tags: + - Lists + operationId: updateList + summary: Update a List + description: 'Official operationId: put-lists-id.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/TrelloList' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: name + in: query + description: New name for the list + schema: + type: string + - name: closed + in: query + description: Whether the list should be closed (archived) + schema: + type: boolean + - name: idBoard + in: query + description: ID of a board the list should be moved to + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: pos + in: query + description: 'New position for the list: `top`, `bottom`, or a positive floating point number' + schema: + oneOf: + - type: number + format: float + - type: string + enum: + - top + - bottom + - name: subscribed + in: query + description: Whether the active member is subscribed to this list + schema: + type: boolean + /lists/{id}/cards: + get: + tags: + - Lists + operationId: listListCards + summary: Get Cards in a List + description: 'Official operationId: get-lists-id-cards.' + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Card' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + description: The ID of the list + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + /cards: + post: + tags: + - Cards + operationId: createCard + summary: Create a new Card + description: 'Official operationId: post-cards.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Card' + '401': + description: Unauthorized + parameters: + - name: name + in: query + description: The name for the card + schema: + type: string + - name: desc + in: query + description: The description for the card + schema: + type: string + - name: pos + in: query + description: The position of the new card. `top`, `bottom`, or a positive float + schema: + oneOf: + - type: string + enum: + - top + - bottom + - type: number + format: float + minimum: 0 + - name: due + in: query + description: A due date for the card + schema: + type: string + format: date + - name: start + in: query + description: The start date of a card, or `null` + schema: + nullable: true + type: string + format: date + - name: dueComplete + in: query + description: Whether the status of the card is complete + schema: + type: boolean + - name: idList + in: query + required: true + description: The ID of the list the card should be created in + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: idMembers + in: query + description: Comma-separated list of member IDs to add to the card + schema: + type: array + items: + oneOf: + - type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: idLabels + in: query + description: Comma-separated list of label IDs to add to the card + schema: + type: array + items: + oneOf: + - type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: urlSource + in: query + description: A URL starting with `http://` or `https://`. The URL will be attached to the card + upon creation. + schema: + type: string + format: url + - name: fileSource + in: query + schema: + type: string + format: binary + - name: mimeType + in: query + description: The mimeType of the attachment. Max length 256 + schema: + type: string + - name: idCardSource + in: query + description: The ID of a card to copy into the new card + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: keepFromSource + in: query + description: 'If using `idCardSource` you can specify which properties to copy over. `all` or + comma-separated list of: `attachments,checklists,customFields,comments,due,start,labels,members,start,stickers`' + schema: + type: string + default: all + enum: + - all + - attachments + - checklists + - comments + - customFields + - due + - start + - labels + - members + - start + - stickers + - name: address + in: query + description: For use with/by the Map View + schema: + type: string + - name: locationName + in: query + description: For use with/by the Map View + schema: + type: string + - name: coordinates + in: query + description: For use with/by the Map View. Should take the form latitude,longitude + schema: + type: string + - name: cardRole + in: query + description: For displaying cards in different ways based on the card name. Board cards must have + a name that is a link to a Trello board. Mirror cards must have a name that is a link to a Trello + card. + schema: + type: string + enum: + - separator + - board + - mirror + - link + nullable: true + /cards/{id}: + get: + tags: + - Cards + operationId: getCard + summary: Get a Card + description: 'Official operationId: get-cards-id.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Card' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: fields + in: query + description: '`all` or a comma-separated list of [fields](/cloud/trello/guides/rest-api/object-definitions/). + **Defaults**: `badges, checkItemStates, closed, dateLastActivity, desc, descData, due, start, + idBoard, idChecklists, idLabels, idList, idMembers, idShort, idAttachmentCover, manualCoverAttachment, + labels, name, pos, shortUrl, url`' + schema: + type: string + - name: actions + in: query + description: See the [Actions Nested Resource](/cloud/trello/guides/rest-api/nested-resources/#actions-nested-resource) + schema: + type: string + - name: attachments + in: query + description: '`true`, `false`, or `cover`' + schema: + type: string + oneOf: + - type: string + enum: + - cover + - type: boolean + default: false + - name: attachment_fields + in: query + description: '`all` or a comma-separated list of attachment [fields](/cloud/trello/guides/rest-api/object-definitions/)' + schema: + type: string + default: all + - name: members + in: query + description: Whether to return member objects for members on the card + schema: + type: boolean + default: false + - name: member_fields + in: query + description: '`all` or a comma-separated list of member [fields](/cloud/trello/guides/rest-api/object-definitions/). + **Defaults**: `avatarHash, fullName, initials, username`' + schema: + type: string + - name: membersVoted + in: query + description: Whether to return member objects for members who voted on the card + schema: + type: boolean + default: false + - name: memberVoted_fields + in: query + description: '`all` or a comma-separated list of member [fields](/cloud/trello/guides/rest-api/object-definitions/). + **Defaults**: `avatarHash, fullName, initials, username`' + schema: + type: string + - name: checkItemStates + in: query + schema: + type: boolean + default: false + - name: checklists + in: query + description: Whether to return the checklists on the card. `all` or `none` + schema: + type: string + default: none + - name: checklist_fields + in: query + description: '`all` or a comma-separated list of `idBoard,idCard,name,pos`' + schema: + type: string + default: all + - name: board + in: query + description: Whether to return the board object the card is on + schema: + type: boolean + default: false + - name: board_fields + in: query + description: '`all` or a comma-separated list of board [fields](/cloud/trello/guides/rest-api/object-definitions/#board-object). + **Defaults**: `name, desc, descData, closed, idOrganization, pinned, url, prefs`' + schema: + type: string + - name: list + in: query + description: See the [Lists Nested Resource](/cloud/trello/guides/rest-api/nested-resources/) + schema: + type: boolean + default: false + - name: pluginData + in: query + description: Whether to include pluginData on the card with the response + schema: + type: boolean + default: false + - name: stickers + in: query + description: Whether to include sticker models with the response + schema: + type: boolean + default: false + - name: sticker_fields + in: query + description: '`all` or a comma-separated list of sticker [fields](/cloud/trello/guides/rest-api/object-definitions/)' + schema: + type: string + default: all + - name: customFieldItems + in: query + description: Whether to include the customFieldItems + schema: + type: boolean + default: false + put: + tags: + - Cards + operationId: updateCard + summary: Update a Card + description: 'Official operationId: put-cards-id.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Card' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: name + in: query + description: The new name for the card + schema: + type: string + - name: desc + in: query + description: The new description for the card + schema: + type: string + - name: closed + in: query + description: 'Whether the card should be archived (closed: true)' + schema: + type: boolean + - name: idMembers + in: query + description: Comma-separated list of member IDs + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: idAttachmentCover + in: query + description: The ID of the image attachment the card should use as its cover, or null for none + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: idList + in: query + description: The ID of the list the card should be in + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: idLabels + in: query + description: Comma-separated list of label IDs + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: idBoard + in: query + description: The ID of the board the card should be on + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: pos + in: query + description: The position of the card in its list. `top`, `bottom`, or a positive float + schema: + oneOf: + - type: string + enum: + - top + - bottom + - type: number + format: float + - name: due + in: query + description: When the card is due, or `null` + schema: + nullable: true + type: string + format: date + - name: start + in: query + description: The start date of a card, or `null` + schema: + nullable: true + type: string + format: date + - name: dueComplete + in: query + description: Whether the status of the card is complete + schema: + type: boolean + - name: subscribed + in: query + description: Whether the member is should be subscribed to the card + schema: + type: boolean + - name: address + in: query + description: For use with/by the Map View + schema: + type: string + - name: locationName + in: query + description: For use with/by the Map View + schema: + type: string + - name: coordinates + in: query + description: For use with/by the Map View. Should be latitude,longitude + schema: + type: string + - name: cover + in: query + description: "Updates the card's cover\n | Option | Values | About |\n |--------|--------|-------|\n\ + \ | color | `pink`, `yellow`, `lime`, `blue`, `black`, `orange`, `red`, `purple`, `sky`, `green`\ + \ | Makes the cover a solid color . |\n | brightness | `dark`, `light` | Determines whether\ + \ the text on the cover should be dark or light.\n | url | An unsplash URL: https://images.unsplash.com\ + \ | Used if making an image the cover. Only Unsplash URLs work.\n | idAttachment | ID of an\ + \ attachment on the card | Used if setting an attached image as the cover. |\n | size | `normal`,\ + \ `full` | Determines whether to show the card name on the cover, or below it. |\n \n `brightness`\ + \ can be sent alongside any of the other parameters, but all of the other parameters are mutually\ + \ exclusive; you can not have the cover be a `color` and an `idAttachment` at the same time.\ + \ \n \n On the brightness options, setting it to light will make the text on the card cover\ + \ dark:\n ![](/cloud/trello/images/rest/cards/cover-brightness-dark.png)\n \n And vice versa,\ + \ setting it to dark will make the text on the card cover light: \n ![](/cloud/trello/images/rest/cards/cover-brightness-light.png) " + schema: + type: object + properties: + value: + type: object + description: "An object containing information regarding the card's cover \n `brightness`\ + \ can be sent alongside any of the other parameters, but all of the other parameters are\ + \ mutually exclusive; you can not have the cover be a color and an `idAttachment` at the\ + \ same time." + properties: + color: + type: string + enum: + - pink + - yellow + - lime + - blue + - black + - orange + - red + - purple + - sky + - green + description: 'One of: `pink, yellow, lime, blue, black, orange, red, purple, sky, green`' + brightness: + type: string + enum: + - dark + - light + description: Determines whether the text on the cover should be dark or light. Setting + it to `light` will make the text on the card cover dark. And vice versa, setting it + to dark will make the text on the card cover light + url: + type: string + format: url + description: Used if making an image the cover. Only Unsplash URLs (https://images.unsplash.com/) + work. + /members/{id}/organizations: + get: + tags: + - Members + operationId: listOrganizations + summary: Get Member's Organizations + description: 'Official operationId: get-members-id-organizations.' + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Organization' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + description: The ID or username of the member + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: filter + in: query + description: 'One of: `all`, `members`, `none`, `public` (Note: `members` filters to only private + Workspaces)' + schema: + type: string + default: all + enum: + - all + - members + - none + - public + - name: fields + in: query + description: '`all` or a comma-separated list of organization [fields](/cloud/trello/guides/rest-api/object-definitions/)' + schema: + type: string + enum: *id001 + - name: paid_account + in: query + description: Whether or not to include paid account information in the returned workspace object + schema: + type: boolean + default: false + /organizations/{id}: + get: + tags: + - Organizations + operationId: getOrganization + summary: Get an Organization + description: 'Official operationId: get-organizations-id.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Organization' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + /labels: + post: + tags: + - Labels + operationId: createLabel + summary: Create a Label + description: 'Official operationId: post-labels.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Label' + '401': + description: Unauthorized + parameters: + - name: name + in: query + required: true + description: Name for the label + schema: + type: string + - name: color + in: query + required: true + description: The color for the label. + schema: + type: string + nullable: true + enum: + - yellow + - purple + - blue + - red + - green + - orange + - black + - sky + - pink + - lime + - name: idBoard + in: query + required: true + description: The ID of the Board to create the Label on. + schema: + type: string + /webhooks/: + post: + tags: + - Webhooks + operationId: createWebhook + summary: Create a Webhook + description: 'Official operationId: post-webhooks.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Webhook' + '401': + description: Unauthorized + parameters: + - name: description + in: query + description: A string with a length from `0` to `16384`. + schema: + type: string + maxLength: 16384 + minLength: 0 + - name: callbackURL + in: query + required: true + description: A valid URL that is reachable with a `HEAD` and `POST` request. + schema: + type: string + format: url + - name: idModel + in: query + required: true + description: ID of the model to be monitored + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: active + in: query + description: Determines whether the webhook is active and sending `POST` requests. + schema: + type: boolean + /webhooks/{id}: + get: + tags: + - Webhooks + operationId: getWebhook + summary: Get a Webhook + description: 'Official operationId: get-webhooks-id.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Webhook' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + put: + tags: + - Webhooks + operationId: updateWebhook + summary: Update a Webhook + description: 'Official operationId: put-webhooks-id.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Webhook' + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: description + in: query + description: A string with a length from `0` to `16384`. + schema: + type: string + maxLength: 16384 + minLength: 0 + - name: callbackURL + in: query + description: A valid URL that is reachable with a `HEAD` and `POST` request. + schema: + type: string + format: url + - name: idModel + in: query + description: ID of the model to be monitored + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + - name: active + in: query + description: Determines whether the webhook is active and sending `POST` requests. + schema: + type: boolean + delete: + tags: + - Webhooks + operationId: deleteWebhook + summary: Delete a Webhook + description: 'Official operationId: delete-webhooks-id.' + responses: + '200': + description: Success + '401': + description: Unauthorized + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ + /tokens/{token}/webhooks: + get: + tags: + - Webhooks + operationId: listTokenWebhooks + summary: Get Webhooks for Token + description: 'Official operationId: get-tokens-token-webhooks.' + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Webhook' + '401': + description: Unauthorized + parameters: + - name: token + in: path + required: true + schema: + type: string + post: + tags: + - Webhooks + operationId: createTokenWebhook + summary: Create Webhooks for Token + description: 'Official operationId: post-tokens-token-webhooks.' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Webhook' + '401': + description: Unauthorized + parameters: + - name: token + in: path + required: true + schema: + type: string + - name: description + in: query + description: A description to be displayed when retrieving information about the webhook. + schema: + type: string + - name: callbackURL + in: query + required: true + description: The URL that the webhook should POST information to. + schema: + type: string + format: url + - name: idModel + in: query + required: true + description: ID of the object to create a webhook on. + schema: + type: string + pattern: ^[0-9a-fA-F]{24}$ +components: + securitySchemes: + APIKey: + type: apiKey + in: query + name: key + description: Trello Power-Up API key (official swagger.v3.json). + APIToken: + type: apiKey + in: query + name: token + description: User token authorized for the API key (official swagger.v3.json). + OAuth2: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://auth.atlassian.com/authorize + tokenUrl: https://auth.atlassian.com/authorize/oauth/token + scopes: + read:board:trello: Read cards, lists, and comments in boards. + write:board:trello: Create and update cards, lists, and comments in boards. + write:board:membership:trello: Add, remove, or modify memberships on boards. + read:organization:trello: Read workspaces. + write:organization:trello: Update workspaces. + write:organization:membership:trello: Add, remove, or modify memberships on workspaces. + read:member:trello: Read email address, public name, public avatar, and memberships of boards, + workspaces, and enterprises. + write:member:trello: Upload custom emojis and stickers, star boards, and save searches. + read:enterprise:trello: Read enterprises. + write:enterprise:trello: Update and manage enterprises. + schemas: + TrelloID: + type: string + pattern: ^[0-9a-fA-F]{24}$ + example: 5abbe4b7ddc1b351ef961414 + Color: + type: string + enum: + - yellow + - purple + - blue + - red + - green + - orange + - black + - sky + - pink + - lime + nullable: true + Member: + type: object + properties: + id: + type: string + pattern: ^[0-9a-fA-F]{24}$ + fullName: + type: string + username: + type: string + url: + type: string + format: url + email: + type: string + avatarUrl: + type: string + format: url + initials: + type: string + idBoards: + type: array + items: + type: string + pattern: ^[0-9a-fA-F]{24}$ + idOrganizations: + type: array + items: + type: string + pattern: ^[0-9a-fA-F]{24}$ + bio: + type: string + memberType: + type: string + enum: + - normal + - ghost + status: + type: string + enum: + - disconnected + Board: + type: object + properties: + id: + type: string + pattern: ^[0-9a-fA-F]{24}$ + name: + type: string + description: The name of the board. + desc: + type: string + closed: + type: boolean + idOrganization: + type: string + pattern: ^[0-9a-fA-F]{24}$ + url: + type: string + format: url + shortUrl: + type: string + format: url + shortLink: + type: string + dateLastActivity: + type: string + format: date + prefs: + type: object + properties: + permissionLevel: + type: string + enum: + - org + - board + hideVotes: + type: boolean + voting: + type: string + enum: + - disabled + - enabled + comments: + type: string + invitations: + enum: + - admins + - members + selfJoin: + type: boolean + cardCovers: + type: boolean + isTemplate: + type: boolean + cardAging: + type: string + enum: + - pirate + - regular + calendarFeedEnabled: + type: boolean + background: + type: string + pattern: ^[0-9a-fA-F]{24}$ + backgroundImage: + type: string + format: uri + backgroundImageScaled: + type: array + items: + type: object + properties: + width: + description: The width of the image. + type: integer + example: 100 + height: + description: The height of the image. + type: integer + example: 64 + url: + description: The URL of the image. + format: url + type: string + example: https://trello-backgrounds.s3.amazonaws.com/SharedBackground/100x64/abc/photo-123.jpg + backgroundTile: + type: boolean + backgroundBrightness: + type: string + backgroundBottomColor: + type: string + backgroundTopColor: + type: string + canBePublic: + type: boolean + canBeEnterprise: + type: boolean + canBeOrg: + type: boolean + canBePrivate: + type: boolean + canInvite: + type: boolean + labelNames: + type: object + properties: + green: + type: string + yellow: + type: string + orange: + type: string + red: + type: string + purple: + type: string + blue: + type: string + sky: + type: string + lime: + type: string + pink: + type: string + black: + type: string + starred: + type: boolean + subscribed: + type: boolean + pinned: + type: boolean + idMemberCreator: + type: string + pattern: ^[0-9a-fA-F]{24}$ + enterpriseOwned: + type: boolean + TrelloList: + type: object + properties: + id: + type: string + pattern: ^[0-9a-fA-F]{24}$ + name: + type: string + description: The name of the list + closed: + type: boolean + pos: + type: number + idBoard: + type: string + subscribed: + type: boolean + softLimit: + type: string + Card: + type: object + properties: + id: + type: string + pattern: ^[0-9a-fA-F]{24}$ + name: + type: string + desc: + type: string + closed: + type: boolean + idBoard: + type: string + pattern: ^[0-9a-fA-F]{24}$ + idList: + type: string + pattern: ^[0-9a-fA-F]{24}$ + idMembers: + type: array + items: + oneOf: + - type: string + pattern: ^[0-9a-fA-F]{24}$ + idLabels: + type: array + items: + oneOf: + - type: object + properties: + id: + description: The ID of the label. + type: string + $ref: '#/components/schemas/TrelloID' + idBoard: + type: string + description: The ID of the board the label is on. + $ref: '#/components/schemas/TrelloID' + example: 5abbe4b7ddc1b351ef961414 + name: + type: string + description: The name displayed for the label. + example: Overdue + nullable: true + minLength: 0 + maxLength: 16384 + color: + type: string + $ref: '#/components/schemas/Color' + description: The color of the label. Null means no color and the label will not be shown + on the front of Cards. + - type: string + pattern: ^[0-9a-fA-F]{24}$ + url: + type: string + format: url + shortUrl: + type: string + format: url + shortLink: + type: string + due: + type: string + format: date + nullable: true + dueReminder: + type: string + nullable: true + dateLastActivity: + type: string + format: date-time + pos: + type: number + format: float + subscribed: + type: boolean + cover: + type: object + properties: + idAttachment: + type: string + pattern: ^[0-9a-fA-F]{24}$ + color: + type: string + nullable: true + enum: + - yellow + - purple + - blue + - red + - green + - orange + - black + - sky + - pink + - lime + idUploadedBackground: + type: boolean + nullable: true + size: + type: string + enum: + - normal + brightness: + type: string + enum: + - light + - dark + isTemplate: + type: boolean + badges: + type: object + properties: + attachmentsByType: + type: object + properties: + trello: + type: object + properties: + board: + type: number + card: + type: number + location: + type: boolean + votes: + type: integer + viewingMemberVoted: + type: boolean + subscribed: + type: boolean + fogbugz: + type: string + checkItems: + type: integer + checkItemsChecked: + type: integer + comments: + type: integer + attachments: + type: integer + description: + type: boolean + due: + type: string + format: date + nullable: true + start: + type: string + format: date + nullable: true + dueComplete: + type: boolean + idShort: + type: integer + Organization: + type: object + properties: + id: + type: string + pattern: ^[0-9a-fA-F]{24}$ + name: + type: string + displayName: + type: string + url: + type: string + format: url + idBoards: + type: array + items: + type: string + pattern: ^[0-9a-fA-F]{24}$ + idEnterprise: + type: string + pattern: ^[0-9a-fA-F]{24}$ + dateLastActivity: + type: string + format: date + prefs: + type: object + properties: + boardVisibilityRestrict: + type: object + boardDeleteRestrict: + type: object + attachmentRestrictions: + type: array + items: + type: string + enum: + - computer + - trello + - google-drive + - box + - onedrive + - link + permissionLevel: + type: string + enum: + - org + - private + - public + - enterprise + - domain + offering: + type: string + memberships: + type: array + items: + type: string + pattern: ^[0-9a-fA-F]{24}$ + premiumFeatures: + type: array + items: + type: string + Label: + type: object + properties: + id: + type: string + pattern: ^[0-9a-fA-F]{24}$ + idBoard: + type: string + pattern: ^[0-9a-fA-F]{24}$ + name: + type: string + description: The name displayed for the label. + nullable: true + minLength: 0 + maxLength: 16384 + color: + type: string + nullable: true + enum: + - yellow + - purple + - blue + - red + - green + - orange + - black + - sky + - pink + - lime + Webhook: + type: object + properties: + id: + type: string + pattern: ^[0-9a-fA-F]{24}$ + description: + type: string + idModel: + type: string + pattern: ^[0-9a-fA-F]{24}$ + callbackURL: + type: string + format: url + active: + type: boolean + consecutiveFailures: + type: number + firstConsecutiveFailDate: + type: string + format: date + nullable: true