@zackbart/connecta 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +21 -16
- package/bin/connecta.mjs +1 -0
- package/bin/version.mjs +1 -1
- package/dist/auth-elicitation.js +1 -1
- package/dist/call-recovery.js +25 -0
- package/dist/catalog-service.js +7 -2
- package/dist/config-schema.d.ts +14 -7
- package/dist/config-schema.js +29 -10
- package/dist/config-value-sources.js +6 -3
- package/dist/config.d.ts +26 -8
- package/dist/config.js +38 -16
- package/dist/connector-access.d.ts +2 -1
- package/dist/connectors/guarded-fetch.d.ts +18 -1
- package/dist/connectors/guarded-fetch.js +21 -2
- package/dist/describe-config.d.ts +6 -3
- package/dist/describe-config.js +3 -2
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +1 -0
- package/dist/execute.js +16 -14
- package/dist/index.js +4 -3
- package/dist/invocation.js +39 -4
- package/dist/meta-tools.js +17 -19
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -0
- package/dist/operator-ui/contract.d.ts +5 -4
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +1 -1
- package/dist/operator-ui/view.d.ts +9 -0
- package/dist/operator-ui/view.js +12 -0
- package/dist/program-writes.js +4 -49
- package/dist/provider.d.ts +6 -5
- package/dist/provider.js +3 -4
- package/dist/providers/_shared/google/workspace.js +1 -1
- package/dist/providers/_shared/rest/dispatch.js +41 -0
- package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
- package/dist/providers/_shared/rest/operation-index.js +527 -0
- package/dist/providers/_shared/rest/tools.d.ts +141 -0
- package/dist/providers/_shared/rest/tools.js +569 -0
- package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
- package/dist/providers/_shared/rest/value-safety.js +580 -0
- package/dist/providers/breeze/skill.generated.js +2 -2
- package/dist/providers/cloudflare/graphql.js +385 -0
- package/dist/providers/cloudflare/index.d.ts +47 -51
- package/dist/providers/cloudflare/index.js +188 -1997
- package/dist/providers/cloudflare/openapi.generated.js +3347 -0
- package/dist/providers/cloudflare/rest.d.ts +37 -0
- package/dist/providers/cloudflare/rest.js +1056 -0
- package/dist/providers/cloudflare/skill.generated.js +7 -4
- package/dist/providers/cloudflare/value-safety.js +1247 -0
- package/dist/providers/docs/index.d.ts +1 -1
- package/dist/providers/docs/index.js +2 -2
- package/dist/providers/drive/index.d.ts +1 -1
- package/dist/providers/drive/index.js +1 -1
- package/dist/providers/forms/index.d.ts +1 -1
- package/dist/providers/forms/index.js +2 -2
- package/dist/providers/github/skill.generated.js +2 -2
- package/dist/providers/gmail/index.d.ts +1 -1
- package/dist/providers/gmail/index.js +274 -23
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/linear/index.js +1 -1
- package/dist/providers/mixpanel/index.d.ts +27 -12
- package/dist/providers/mixpanel/index.js +58 -6
- package/dist/providers/mixpanel/operations.js +20 -0
- package/dist/providers/mixpanel/rest.js +603 -0
- package/dist/providers/mixpanel/skill.generated.js +3 -2
- package/dist/providers/mixpanel/value-safety.js +16 -0
- package/dist/providers/notion/index.d.ts +35 -31
- package/dist/providers/notion/index.js +76 -1499
- package/dist/providers/notion/openapi.generated.js +75 -0
- package/dist/providers/notion/rest.d.ts +24 -0
- package/dist/providers/notion/rest.js +1186 -0
- package/dist/providers/notion/skill.generated.js +5 -4
- package/dist/providers/notion/value-safety.js +31 -0
- package/dist/providers/planning-center/index.d.ts +27 -0
- package/dist/providers/planning-center/index.js +286 -24
- package/dist/providers/planning-center/skill.generated.js +24 -4
- package/dist/providers/sheets/index.d.ts +1 -1
- package/dist/providers/sheets/index.js +3 -3
- package/dist/providers/slides/index.d.ts +1 -1
- package/dist/providers/slides/index.js +3 -3
- package/dist/providers/stripe/index.d.ts +26 -20
- package/dist/providers/stripe/index.js +103 -130
- package/dist/providers/stripe/openapi.generated.js +649 -0
- package/dist/providers/stripe/rest.d.ts +14 -0
- package/dist/providers/stripe/rest.js +395 -0
- package/dist/providers/stripe/skill.generated.js +7 -5
- package/dist/providers/stripe/value-safety.js +705 -0
- package/dist/providers/vercel/index.d.ts +31 -22
- package/dist/providers/vercel/index.js +96 -853
- package/dist/providers/vercel/openapi.generated.js +456 -0
- package/dist/providers/vercel/rest.d.ts +20 -0
- package/dist/providers/vercel/rest.js +1002 -0
- package/dist/providers/vercel/skill.generated.js +4 -5
- package/dist/providers/vercel/value-safety.js +328 -0
- package/dist/providers/wispr-flow/index.d.ts +14 -0
- package/dist/providers/wispr-flow/index.js +117 -0
- package/dist/providers/wispr-flow/skill.generated.js +8 -0
- package/dist/routes/mcp.js +30 -10
- package/dist/routes/operator.js +14 -7
- package/dist/routes/ui-config.js +2 -2
- package/dist/server.js +3 -0
- package/dist/skills.js +5 -4
- package/dist/tool-safety.js +13 -5
- package/dist/types.d.ts +1 -1
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +1 -1
- package/dist/usage-guide.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/write-outcome.js +47 -0
- package/documentation/README.md +1 -0
- package/documentation/architecture.md +155 -28
- package/documentation/auth.md +86 -27
- package/documentation/code-mode.md +81 -72
- package/documentation/deploying.md +10 -5
- package/documentation/integrating.md +72 -9
- package/documentation/meta-tools.md +52 -38
- package/documentation/operating.md +11 -8
- package/documentation/operator-tests.md +2 -3
- package/documentation/operator-ui.md +9 -3
- package/documentation/provider-auth-migration.md +308 -0
- package/documentation/provider-migration-0.29.md +18 -6
- package/examples/worker/AGENTS.md +5 -0
- package/examples/worker/README.md +2 -2
- package/examples/worker/src/connecta.config.ts +5 -0
- package/package.json +7 -2
- package/templates/node/AGENTS.md +6 -4
- package/templates/node/README.md +6 -4
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +5 -0
- package/dist/providers/vercel/mcp-ownership.js +0 -212
- package/dist/tool-safety.d.ts +0 -9
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Generated by scripts/generate-providers.mjs; do not edit.
|
|
2
2
|
export const skill = {
|
|
3
|
-
"content": "\n##
|
|
3
|
+
"content": "\n## Notion's data model\n\n- A database is a container; its rows and schema live in a *data source*\n inside it. Queries, schema reads, and new rows take the data source, and\n Notion rejects a database id in its place.\n- Property names in filters, sorts, and writes must match the schema exactly,\n including case. Read the schema before composing one.\n- A `status` write must use an existing option name; inventing one fails.\n A new `select` or `multi_select` name does not fail: when the connection\n can write to the data source, Notion adds it to the schema as a new\n option. Use the options the schema lists unless a schema change is meant.\n- Writes **replace** a property. Sending one item to a `multi_select` or\n `relation` drops the others, so send the complete intended value.\n- `rollup`, `formula`, `unique_id`, and the created and edited fields are\n computed. They cannot be written.\n- A data source's title column is rarely called \"title\". Read its name from\n the schema before creating or renaming a row.\n- Notion has **no idempotency key**. A retried page create or comment makes a\n duplicate. Confirm with a search before repeating a write that may have\n partially succeeded.\n",
|
|
4
4
|
"instructionsHeading": "Workspace instructions",
|
|
5
5
|
"fragments": {
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
6
|
+
"oauth_head": "# Notion MCP usage\n\nOfficial MCP interface: tool names, descriptions, argument schemas, and result\nschemas come from Notion's live server. Connecta preserves that catalog and\napplies release-reviewed classification. Reviewed writes stay writes even when Notion claims they only read.\n\nWorkspace purpose: ",
|
|
7
|
+
"oauth": "\n\n- This connection acts as its OAuth-authorized user and reaches what that user\n can. It holds no integration token. An internal integration (bot) is a\n separate connector with `auth: { type: \"token\" }` under another id; never\n switch to it to recover a failed call here.\n- Discover the live catalog before assuming a tool exists. Notion can gate\n tools by workspace, account, client, and rollout independently of Connecta.\n `notion-get-tool-access` reports what the workspace's plan allows.\n- Start broad discovery with `notion-search`, then use `notion-fetch` on\n the exact page, database, data source, or object before changing it.\n- Use the live input schema as the contract. Notion owns these MCP schemas;\n the REST schemas of Connecta's token connector do not apply to MCP tools with\n similar names.\n- Session and agent tools can launch asynchronous work. Read session state and\n events before sending another message, waiting, or stopping a session.\n- File uploads and attachment tools create durable workspace state. Keep\n downloads inside the requested task and do not expose signed attachment URLs.\n `notion-download-skill` returns a signed, expiring archive URL by design;\n the token connector's REST redaction does not apply to hosted tools, so\n use that URL for the download and do not repeat it in replies.\n- Connecta's generic REST tools (`notion_api_*`) and the `integration_*`\n helpers exist only on a token connector.\n- An `auth_required` failure means this connector's OAuth grant is missing or\n expired. Run `authorize_connector` for this connector id, then retry.\n",
|
|
8
|
+
"key": "\n## Identity and tools\n\nThis is Connecta's REST connector to `api.notion.com`, acting as one internal\nintegration (a bot) with an operator-managed integration token. It reaches\nexactly the pages and data sources shared with that integration, and the\nintegration's capabilities decide which calls succeed: comment and\nuser-information capabilities are off by default. It never acts as a person.\nHosted Notion MCP is a separate OAuth connector.\n\n- Named tools cover the common path and flatten Notion's payloads:\n `integration_search` (titles), `integration_get_page` (properties),\n `integration_get_page_content` (blocks as text),\n `integration_get_data_source_schema`, and `integration_query_data_source`\n (rows). `integration_create_page` and `integration_append_blocks` author\n Notion's rich-text and block shapes from plain text, Markdown, or checklists.\n- Four generic tools reach every other operation in the pinned API index.\n `notion_api_search` finds operations, not content. `notion_api_details`\n returns one operation's parameters. `notion_api_read` calls GETs plus the\n read-only POST queries: search, data source and meeting note queries, and\n agent, session, and session event queries. `notion_api_write` calls every\n other POST, PATCH, and DELETE with `method` and `path` stated.\n- Every generic call is checked against the index before it is sent. An\n unknown path returns the nearest operations, and an unknown or missing\n parameter returns `validation.issues` with the accepted names. Nothing\n reached Notion, so fix the call from that answer.\n- The generic set replaces the old one-operation tools. Read a database\n container with `GET /v1/databases/{database_id}`, users with `GET /v1/users`\n or `GET /v1/users/me`, and comments with `GET /v1/comments`. Update\n properties, trash or restore (`in_trash`) with `PATCH /v1/pages/{page_id}`,\n and comment with `POST /v1/comments` (`rich_text` or `markdown`).\n- Refused by design: Notion's OAuth token endpoints (`/v1/oauth/*`) and\n multipart file sends. Import a file with `POST /v1/file_uploads` and\n `mode: \"external_url\"` instead.\n- Notion-hosted file URLs in pages and blocks are pre-signed: Connecta\n withholds their signature, credential, and security-token parameters, so a\n returned URL names the file but does not download it. File links inside\n page Markdown come back as Notion sent them: use one only for its download\n and do not repeat it in replies. Credential-named\n fields read as `\"[redacted]\"`, a database property named like a credential\n (`API token`) included. Failures of the OAuth, file upload, bot user, and\n agent operations carry Notion's code, not its message.\n- Not here: the hosted MCP's user identity, its search across connected\n apps, page duplication, team and skill search, attachment downloads, and\n page-to-skill conversion. Those need an OAuth connector.\n\n## Databases contain data sources\n\n- The id in a Notion database URL is a **database id**.\n `GET /v1/databases/{database_id}` through `notion_api_read` lists its\n `data_sources`, usually one. `integration_search` returns data sources\n directly and skips that step.\n- `integration_get_data_source_schema` and `integration_query_data_source`\n take the **data_source_id**, and so does `integration_create_page` when\n adding a row.\n\nSo the sequence for \"find rows in this database\" is the database read, then\n`integration_get_data_source_schema`, then `integration_query_data_source`.\n\n## Reading\n\n- `integration_get_page` returns properties. To read or summarize a whole\n body, prefer `notion_api_read` `GET /v1/pages/{page_id}/markdown`: one\n request returns every nested level as Markdown (headings, lists, to-dos,\n toggles, tables, code, links, mentions, equations). A huge page comes back\n with `truncated: true` and the unread blocks in `unknown_block_ids`; read\n each id as a `page_id` on the same path.\n- `integration_get_page_content` returns the body as flat blocks with ids,\n for quoting a block or appending after one. Nested blocks (toggles, list\n children, table rows) need `depth`, and each level multiplies requests. A\n deep read stops at an internal ceiling and reports `truncated: true` rather\n than spending the whole rate-limit budget.\n- Notion truncates `title`, `rich_text`, `relation`, and `people` at 25\n entries. `integration_get_page` reports each in `truncated_properties` as\n `{ name, id }`; read the complete value with `notion_api_read`\n `GET /v1/pages/{page_id}/properties/{property_id}` and follow its cursor.\n- Every named read projects Notion's payload to ids, plain text, and\n flattened property values. Pass `raw: true` for Notion's untouched response\n when a specific field is missing. It is much larger. Narrow\n `integration_query_data_source` and `integration_get_page` with `properties`\n when the goal is fewer fields. `integration_get_page_content` with\n `raw: true` returns one level and ignores `depth`.\n\n## Writing content\n\n`integration_append_blocks` adds children and nothing else. It cannot move,\nreorder, or replace an existing block, and a block appended through the API can\nnever be relocated by it afterwards. Get the position right the first time with\n`position` and `after_block_id`. Notion caps one call at 100 blocks. Text\nlonger than Notion's 2,000-character run limit is split into runs for you.\n\nFor formatted content, write Markdown to a page with `notion_api_write`\n`PATCH /v1/pages/{page_id}/markdown`. Append with\n`{\"type\":\"insert_content\",\"insert_content\":{\"content\":\"## Notes\\n- one\",\"position\":{\"type\":\"end\"}}}`\n(`start` prepends). Edit text in place with `update_content`, whose\n`content_updates` are `{ old_str, new_str }` pairs copied exactly from the\npage's Markdown, and `replace_content` swaps the whole page for `new_str`.\nNotion refuses an edit that would delete a child page or database with a\n`validation_error` naming them. Removing ordinary text needs no flag; to delete\nthose children, set `allow_deleting_content: true` inside the command body\n(`{\"type\":\"update_content\",\"update_content\":{\"content_updates\":[…],\"allow_deleting_content\":true}}`),\nand leave it out unless the task is to delete them. `integration_create_page`\ntakes the same Markdown for a new page's body.\n\n## Pagination\n\n- Named list tools take `page_size` (max 100) and return `has_more` with\n `next_cursor`; pass it back as `start_cursor`.\n- Generic results arrive as `{ status, data, page? }`. On a list,\n `page.next` is the cursor and `page.param` is `start_cursor`: send it in\n `query` for a GET, and in `body` when `page.in` is `body`, as on the POST\n queries. Pass `select` dot paths relative to `data`, such as `results.id`;\n `page` survives any `select`.\n- A view query is created with `notion_api_write`\n `POST /v1/views/{view_id}/queries`, a write because Notion stores it until\n `expires_at`. Its first page names the continuation in `page.path`: read\n `GET /v1/views/{view_id}/queries/{query_id}` with `start_cursor` in `query`.\n- Meeting-note queries have no cursor. `limit` caps at 50, and\n `page.hasMore` without `page.next` means more notes exist: narrow the\n filter (for example by `created_time`) rather than paging.\n- A data source query stops at 10,000 rows. `integration_query_data_source`\n then returns `incomplete: true`, and a generic query `page.hasMore` without\n `page.next`: narrow the filter rather than trusting the count.\n- Cursors are opaque: never parse or construct one. Follow pages inside\n `execute_code` and reduce there.\n\n## Failures worth reading carefully\n\n- **404** means \"no such object\" *or* \"not shared with this integration\", and\n Notion will not say which. Never conclude a page was deleted from it; check\n that the page is shared with the integration in Notion.\n- **403** is not an expired token. The integration is missing a capability\n (comment capabilities are off by default), the object was never shared, or\n the workspace is past its plan's block limit; Notion's message says which.\n Re-authorizing cannot fix it; an operator must change it in Notion.\n- **429** carries a retry window. Notion allows each integration 180 requests\n a minute (600 on Business and Enterprise) plus a budget shared by the whole\n workspace; Connecta admits 180 calls a minute, three at a time, per runtime.\n Wait out the window rather than retrying immediately. A 429 that says the\n integration's API access is blocked is not retryable.\n- **5xx on a write** may still have been saved. When Notion names the\n object it committed, the failure says so: read that object instead of\n repeating the write. Otherwise check the target before trying again.\n",
|
|
9
|
+
"shared": "\n## Notion's data model\n\n- A database is a container; its rows and schema live in a *data source*\n inside it. Queries, schema reads, and new rows take the data source, and\n Notion rejects a database id in its place.\n- Property names in filters, sorts, and writes must match the schema exactly,\n including case. Read the schema before composing one.\n- A `status` write must use an existing option name; inventing one fails.\n A new `select` or `multi_select` name does not fail: when the connection\n can write to the data source, Notion adds it to the schema as a new\n option. Use the options the schema lists unless a schema change is meant.\n- Writes **replace** a property. Sending one item to a `multi_select` or\n `relation` drops the others, so send the complete intended value.\n- `rollup`, `formula`, `unique_id`, and the created and edited fields are\n computed. They cannot be written.\n- A data source's title column is rarely called \"title\". Read its name from\n the schema before creating or renaming a row.\n- Notion has **no idempotency key**. A retried page create or comment makes a\n duplicate. Confirm with a search before repeating a write that may have\n partially succeeded.\n"
|
|
9
10
|
}
|
|
10
11
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Notion's reviewed value-safety table (#801), on the shared mechanism in
|
|
2
|
+
// `../_shared/rest/value-safety.ts`: every operation the detector flags in
|
|
3
|
+
// the pinned spec (`value-safety.candidates.json`) has one verdict here, and
|
|
4
|
+
// `value-safety.node.test.ts` runs the shared harness over it.
|
|
5
|
+
//
|
|
6
|
+
// Notion's API returns little that is a credential: an integration token
|
|
7
|
+
// never appears in a response. The OAuth endpoints mint, introspect, and
|
|
8
|
+
// revoke tokens with a public integration's client secret and are refused.
|
|
9
|
+
// A file upload's `upload_url` is Notion's own send endpoint and needs the
|
|
10
|
+
// integration token, so it is kept. Notion-hosted file URLs in pages and
|
|
11
|
+
// blocks are pre-signed: the shared URL rule withholds their signature,
|
|
12
|
+
// credential, and security-token query parameters on every response, so a
|
|
13
|
+
// returned URL names the file without granting access to it.
|
|
14
|
+
import { refuse, safe } from "../_shared/rest/value-safety.js";
|
|
15
|
+
const OAUTH = "Notion's OAuth token endpoints are not reachable through Connecta: they mint and revoke credentials with a public integration's client secret, which this connector never holds.";
|
|
16
|
+
const UPLOAD = "A file upload's upload_url is Notion's own send endpoint, which needs the integration token; it grants nothing alone.";
|
|
17
|
+
const AGENT = "Custom agents name their connected MCP server's host, never its URL or credentials.";
|
|
18
|
+
const OPERATIONS = {
|
|
19
|
+
"POST /v1/oauth/token": refuse(OAUTH),
|
|
20
|
+
"POST /v1/oauth/introspect": refuse(OAUTH),
|
|
21
|
+
"POST /v1/oauth/revoke": refuse(OAUTH),
|
|
22
|
+
"GET /v1/users/me": safe("Answers the integration's bot user; no token."),
|
|
23
|
+
"GET /v1/file_uploads": safe(UPLOAD, ["results.upload_url"]),
|
|
24
|
+
"POST /v1/file_uploads": safe(UPLOAD, ["upload_url"]),
|
|
25
|
+
"GET /v1/file_uploads/{file_upload_id}": safe(UPLOAD, ["upload_url"]),
|
|
26
|
+
"POST /v1/file_uploads/{file_upload_id}/complete": safe(UPLOAD, ["upload_url"]),
|
|
27
|
+
"POST /v1/file_uploads/{file_upload_id}/send": safe(UPLOAD, ["upload_url"]),
|
|
28
|
+
"POST /v1/agents/query": safe(AGENT, ["results.connections.account.server_host"]),
|
|
29
|
+
"GET /v1/agents/{agent_id}": safe(AGENT, ["connections.account.server_host"]),
|
|
30
|
+
};
|
|
31
|
+
export const NOTION_VALUE_SAFETY = { title: "Notion", operations: OPERATIONS };
|
|
@@ -4,6 +4,12 @@ export declare const PLANNING_CENTER_API_BASE_URL = "https://api.planningcentero
|
|
|
4
4
|
/** The products behind `/<app>/v2`, in the order their guides list them. */
|
|
5
5
|
export declare const PLANNING_CENTER_APPS: readonly ["people", "services", "groups", "check-ins", "calendar", "registrations", "giving", "publishing", "webhooks", "api"];
|
|
6
6
|
export type PlanningCenterApp = (typeof PLANNING_CENTER_APPS)[number];
|
|
7
|
+
/**
|
|
8
|
+
* A product a deployment may remove from this connection. People is the one
|
|
9
|
+
* it may not: every product joins through its person ids, and the credential
|
|
10
|
+
* test reads `/people/v2/me`.
|
|
11
|
+
*/
|
|
12
|
+
export type PlanningCenterExcludableApp = Exclude<PlanningCenterApp, "people">;
|
|
7
13
|
/**
|
|
8
14
|
* One reviewed `X-PCO-API-Version` per product, sent on every request.
|
|
9
15
|
*
|
|
@@ -46,6 +52,27 @@ export interface PlanningCenterOptions {
|
|
|
46
52
|
userAgent?: string;
|
|
47
53
|
/** Default `per_page` for list tools. Defaults to 25; Planning Center's maximum is 100. */
|
|
48
54
|
defaultPageSize?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Products this connection never reaches, e.g. `["giving"]` for a
|
|
57
|
+
* deployment that keeps agents away from donor and financial data. Their
|
|
58
|
+
* named tools leave the catalog, the guide and description stop naming
|
|
59
|
+
* them, and the hatches refuse their paths before any request is sent.
|
|
60
|
+
* While any product is excluded, hatch paths must be literal, People lists
|
|
61
|
+
* are unreachable, and existing webhook subscriptions and their payloads
|
|
62
|
+
* are refused, because each could carry an excluded product's data.
|
|
63
|
+
* Defaults to none.
|
|
64
|
+
*
|
|
65
|
+
* Exclusion guarantees agents reach no excluded-product endpoint, no live
|
|
66
|
+
* cross-product query (People lists), and no webhook payload or
|
|
67
|
+
* subscription toggle. It does not hide records the church's automations
|
|
68
|
+
* or staff have copied into a reachable product: a Giving automation that
|
|
69
|
+
* adds first-time donors to a People workflow leaves readable workflow
|
|
70
|
+
* cards, as do notes, custom fields, and form answers. For a hard
|
|
71
|
+
* boundary, use a personal access token from a user with No Access in
|
|
72
|
+
* the excluded product; Planning Center sets permissions per product, per
|
|
73
|
+
* user, and a token carries its user's. Exclusion is defense in depth.
|
|
74
|
+
*/
|
|
75
|
+
excludeApps?: readonly PlanningCenterExcludableApp[];
|
|
49
76
|
/**
|
|
50
77
|
* Replaces the default per-runtime budget (100 calls per 20 seconds, five at
|
|
51
78
|
* a time). Split it when two connections share one token's user, because
|
|
@@ -237,7 +237,7 @@ function isApp(value) {
|
|
|
237
237
|
* normalization the guarded transport applies — so `..` cannot carry a request
|
|
238
238
|
* from `/people/v2` to `/oauth` or anywhere else outside a product.
|
|
239
239
|
*/
|
|
240
|
-
function appForPath(path) {
|
|
240
|
+
function appForPath(path, apps) {
|
|
241
241
|
let normalized;
|
|
242
242
|
if (path.startsWith("/") && !path.startsWith("//")) {
|
|
243
243
|
try {
|
|
@@ -249,10 +249,145 @@ function appForPath(path) {
|
|
|
249
249
|
}
|
|
250
250
|
const app = normalized === undefined ? undefined : APP_PATH.exec(normalized)?.[1];
|
|
251
251
|
if (!app || !isApp(app)) {
|
|
252
|
-
throw new ConnectorCallError("invalid_args", `A Planning Center path begins with /<app>/v2, where app is one of ${
|
|
252
|
+
throw new ConnectorCallError("invalid_args", `A Planning Center path begins with /<app>/v2, where app is one of ${apps.join(", ")} — for example /people/v2/people. No host, query string, or traversal.`);
|
|
253
253
|
}
|
|
254
254
|
return app;
|
|
255
255
|
}
|
|
256
|
+
/** Display names, in guide order. The guide never lists `api` as a product. */
|
|
257
|
+
const APP_TITLES = {
|
|
258
|
+
people: "People",
|
|
259
|
+
services: "Services",
|
|
260
|
+
groups: "Groups",
|
|
261
|
+
"check-ins": "Check-Ins",
|
|
262
|
+
calendar: "Calendar",
|
|
263
|
+
registrations: "Registrations",
|
|
264
|
+
giving: "Giving",
|
|
265
|
+
publishing: "Publishing",
|
|
266
|
+
webhooks: "Webhooks",
|
|
267
|
+
api: "API",
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* A path's segments, each percent-decoded once, or a refusal when the path is
|
|
271
|
+
* not literal. While a product is excluded no path may lean on normalization:
|
|
272
|
+
* a dot segment, an empty segment, a backslash, whitespace, or an encoded
|
|
273
|
+
* `/`, `\`, `?`, `#`, or `%` could each mean one thing here and another to a
|
|
274
|
+
* server that decodes or resolves it, so none is admitted at all rather than
|
|
275
|
+
* reasoned about.
|
|
276
|
+
*/
|
|
277
|
+
function literalSegments(path) {
|
|
278
|
+
const ambiguous = () => new ConnectorCallError("invalid_args", "While this deployment excludes a Planning Center product, a path must be literal: no . or .. segments, empty segments, backslashes, whitespace, or percent-encoded /, \\, ?, #, or %.");
|
|
279
|
+
if (!path.startsWith("/"))
|
|
280
|
+
throw ambiguous();
|
|
281
|
+
const raw = path.slice(1).split("/");
|
|
282
|
+
return raw.map((segment, index) => {
|
|
283
|
+
if (segment === "" && index !== raw.length - 1)
|
|
284
|
+
throw ambiguous();
|
|
285
|
+
let decoded;
|
|
286
|
+
try {
|
|
287
|
+
decoded = decodeURIComponent(segment);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
throw ambiguous();
|
|
291
|
+
}
|
|
292
|
+
const control = [...decoded].some((char) => char <= " " || char === "\x7f");
|
|
293
|
+
if (decoded === "." || decoded === ".." || control || /[/\\?#%]/.test(decoded))
|
|
294
|
+
throw ambiguous();
|
|
295
|
+
return decoded;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* People resources a list is built from or answers with. A list's conditions
|
|
300
|
+
* can draw on any product — a Giving condition makes a donor list — and one
|
|
301
|
+
* list can be a condition of another, so while any product is excluded none
|
|
302
|
+
* of them is read, by path or by `include`.
|
|
303
|
+
*/
|
|
304
|
+
const LIST_RESOURCES = new Set(["lists", "list_results", "rules", "conditions"]);
|
|
305
|
+
/** The People tools that read or refresh a list, removed with any exclusion. */
|
|
306
|
+
const LIST_TOOLS = new Set(["list_lists", "list_list_people", "run_list"]);
|
|
307
|
+
/** The webhook subscription collections, current and 2018-08-01. */
|
|
308
|
+
const SUBSCRIPTION_COLLECTIONS = new Set(["webhook_subscriptions", "subscriptions"]);
|
|
309
|
+
/**
|
|
310
|
+
* Refuse, before anything is sent, a request that would reach an excluded
|
|
311
|
+
* product. It reads literal segments, so it runs twice: on the path as given,
|
|
312
|
+
* and on the exact URL the transport is about to fetch (`admit`), which is the
|
|
313
|
+
* one that counts. Five doors lead to an excluded product:
|
|
314
|
+
*
|
|
315
|
+
* - its own paths, in any case or percent-encoding of a literal segment;
|
|
316
|
+
* - People lists, rules, conditions, and results, whose criteria can come from
|
|
317
|
+
* the excluded product, by path or by `include`;
|
|
318
|
+
* - a new webhook subscription to its events, which delivers its records to
|
|
319
|
+
* any URL — so a new subscription must name a reachable product's event;
|
|
320
|
+
* - an existing subscription, changed by id: a path names a subscription by
|
|
321
|
+
* id, not by product, so no subscription is enabled, disabled, deleted, or
|
|
322
|
+
* re-keyed while any product is excluded; and
|
|
323
|
+
* - webhook event and delivery payloads, which carry the record that fired
|
|
324
|
+
* them, refused for every subscription for the same reason.
|
|
325
|
+
*
|
|
326
|
+
* People documents no other `include` or attribute that reaches another
|
|
327
|
+
* product's records.
|
|
328
|
+
*/
|
|
329
|
+
function refuseExcluded(segments, query, request, excluded) {
|
|
330
|
+
const disabled = (app, detail) => new ConnectorCallError("provider_permission_denied", `Planning Center ${APP_TITLES[app]} (/${app}/v2) is disabled in this deployment, so this connection ${detail}. Retrying will not help; only an operator can remove "${app}" from excludeApps.`, { retryable: false });
|
|
331
|
+
const because = (detail) => new ConnectorCallError("provider_permission_denied", `This deployment disables Planning Center ${series([...excluded].map((name) => APP_TITLES[name]))}, and ${detail} Retrying will not help.`, { retryable: false });
|
|
332
|
+
const lower = segments.map((segment) => segment.toLowerCase());
|
|
333
|
+
const app = lower[0] ?? "";
|
|
334
|
+
if (isApp(app) && excluded.has(app))
|
|
335
|
+
throw disabled(app, "does not reach its endpoints");
|
|
336
|
+
// Lists are refused and workflows are not. A list is a live query: reading
|
|
337
|
+
// it evaluates its conditions, Giving ones included, at read time, so it
|
|
338
|
+
// is the excluded product's API by another path. A workflow card is a
|
|
339
|
+
// stored People record. A Giving automation can create one, but so can
|
|
340
|
+
// staff, and once created it is People data, like a note or custom field
|
|
341
|
+
// value typed from a donor's details. Exclusion guards products' APIs,
|
|
342
|
+
// not provenance; refusing workflows would cost every exclusion a core
|
|
343
|
+
// People feature and argue equally for notes, fields, forms, and groups.
|
|
344
|
+
// The hard boundary is the token user's own per-product permissions.
|
|
345
|
+
const lists = "a People list's conditions can draw on a disabled product, so this connection reads no list, list result, rule, or condition.";
|
|
346
|
+
if (app === "people" && lower.some((segment) => LIST_RESOURCES.has(segment)))
|
|
347
|
+
throw because(lists);
|
|
348
|
+
for (const [key, value] of query) {
|
|
349
|
+
if (/include/i.test(key) &&
|
|
350
|
+
value
|
|
351
|
+
.toLowerCase()
|
|
352
|
+
.split(/[\s,.]+/)
|
|
353
|
+
.some((part) => LIST_RESOURCES.has(part))) {
|
|
354
|
+
throw because(lists);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (app !== "webhooks")
|
|
358
|
+
return;
|
|
359
|
+
if (lower.some((segment) => /^(?:events|deliveries)\b/.test(segment))) {
|
|
360
|
+
throw because("a webhook event or delivery payload may carry any product's records, so this connection does not read them. Subscriptions themselves stay readable.");
|
|
361
|
+
}
|
|
362
|
+
if (request.method === "GET" || request.method === "HEAD")
|
|
363
|
+
return;
|
|
364
|
+
const resource = lower.slice(2).filter((segment) => segment !== "");
|
|
365
|
+
if (request.method !== "POST" || resource.length !== 1 || !SUBSCRIPTION_COLLECTIONS.has(resource[0])) {
|
|
366
|
+
throw because("a webhook subscription's id does not name its product, so this connection does not enable, disable, delete, or re-key an existing subscription.");
|
|
367
|
+
}
|
|
368
|
+
const data = asRecord(request.body)["data"];
|
|
369
|
+
const name = Array.isArray(data) ? undefined : asRecord(asRecord(data)["attributes"])["name"];
|
|
370
|
+
const event = typeof name === "string" ? /^([a-z-]+)\.v2\.events\./.exec(name.trim().toLowerCase())?.[1] : undefined;
|
|
371
|
+
if (event === undefined || !isApp(event)) {
|
|
372
|
+
throw because("a new webhook subscription must name one product's event, such as people.v2.events.person.created, in data.attributes.name.");
|
|
373
|
+
}
|
|
374
|
+
if (excluded.has(event))
|
|
375
|
+
throw disabled(event, "cannot subscribe to its webhook events");
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The exclusion, enforced on the exact URL about to be fetched: after the
|
|
379
|
+
* transport has resolved and encoded it, so what is checked is what Planning
|
|
380
|
+
* Center routes. The origin and base path are asserted again here, not taken
|
|
381
|
+
* on trust from the confinement before it.
|
|
382
|
+
*/
|
|
383
|
+
function admit(baseUrl, url, request, excluded) {
|
|
384
|
+
const base = new URL(baseUrl);
|
|
385
|
+
const basePath = base.pathname.replace(/\/+$/, "");
|
|
386
|
+
if (url.origin !== base.origin || !url.pathname.startsWith(`${basePath}/`) || url.hash) {
|
|
387
|
+
throw new ConnectorCallError("invalid_args", "A Planning Center request left the Planning Center API origin.");
|
|
388
|
+
}
|
|
389
|
+
refuseExcluded(literalSegments(url.pathname.slice(basePath.length)), url.searchParams, request, excluded);
|
|
390
|
+
}
|
|
256
391
|
function errorDetail(payload, status) {
|
|
257
392
|
const parts = [];
|
|
258
393
|
for (const entry of asArray(asRecord(payload)["errors"])) {
|
|
@@ -309,9 +444,12 @@ function planningCenterFailure(status, headers, payload) {
|
|
|
309
444
|
retryable: false,
|
|
310
445
|
});
|
|
311
446
|
}
|
|
312
|
-
function planningCenterCall(send, versions) {
|
|
447
|
+
function planningCenterCall(send, versions, excluded) {
|
|
448
|
+
const apps = PLANNING_CENTER_APPS.filter((app) => !excluded.has(app));
|
|
313
449
|
return async (request, ctx) => {
|
|
314
|
-
|
|
450
|
+
if (excluded.size > 0)
|
|
451
|
+
refuseExcluded(literalSegments(request.path), [], request, excluded);
|
|
452
|
+
const app = appForPath(request.path, apps);
|
|
315
453
|
return await send(compact({
|
|
316
454
|
method: request.method,
|
|
317
455
|
path: request.path,
|
|
@@ -787,11 +925,11 @@ function personAttributes(args) {
|
|
|
787
925
|
};
|
|
788
926
|
}
|
|
789
927
|
const WORKFLOW_CARD_ACTIONS = ["promote", "go_back", "skip_step", "snooze", "unsnooze", "remove", "restore"];
|
|
790
|
-
function tools(call, defaultPageSize) {
|
|
928
|
+
function tools(call, defaultPageSize, excluded) {
|
|
791
929
|
const perPage = (args) => args["perPage"] ?? defaultPageSize;
|
|
792
930
|
const list = (spec) => listTool(call, defaultPageSize, spec);
|
|
793
|
-
|
|
794
|
-
|
|
931
|
+
// --- Hatches --------------------------------------------------------------
|
|
932
|
+
const hatches = [
|
|
795
933
|
{
|
|
796
934
|
name: "pco_api_get",
|
|
797
935
|
description: "Call any Planning Center GET endpoint in any product and return its untouched JSON:API body. Prefer a named read; this one does not flatten or resolve includes.",
|
|
@@ -813,7 +951,7 @@ function tools(call, defaultPageSize) {
|
|
|
813
951
|
},
|
|
814
952
|
{
|
|
815
953
|
name: "pco_api_mutate",
|
|
816
|
-
description:
|
|
954
|
+
description: `Call any Planning Center POST, PATCH, or DELETE endpoint with a JSON:API body. The approval-gated hatch for ${excluded.has("giving") ? "writes and actions" : "writes, actions, and money movement"} no named tool covers.`,
|
|
817
955
|
annotations: DESTRUCTIVE,
|
|
818
956
|
inputSchema: input({
|
|
819
957
|
method: {
|
|
@@ -844,7 +982,9 @@ function tools(call, defaultPageSize) {
|
|
|
844
982
|
}), ctx)) ?? null,
|
|
845
983
|
}),
|
|
846
984
|
},
|
|
847
|
-
|
|
985
|
+
];
|
|
986
|
+
// --- People ---------------------------------------------------------------
|
|
987
|
+
const people = [
|
|
848
988
|
{
|
|
849
989
|
name: "get_me",
|
|
850
990
|
description: "Get the Planning Center person this token acts as, with the organization's name and time zone. Supplies personId for my-schedule and assignee questions.",
|
|
@@ -1292,7 +1432,9 @@ function tools(call, defaultPageSize) {
|
|
|
1292
1432
|
};
|
|
1293
1433
|
},
|
|
1294
1434
|
}),
|
|
1295
|
-
|
|
1435
|
+
];
|
|
1436
|
+
// --- Services -------------------------------------------------------------
|
|
1437
|
+
const services = [
|
|
1296
1438
|
list({
|
|
1297
1439
|
name: "list_service_types",
|
|
1298
1440
|
description: "List Services service types — the recurring services such as Sunday Morning. Supplies serviceTypeId for plans and teams.",
|
|
@@ -1596,7 +1738,9 @@ function tools(call, defaultPageSize) {
|
|
|
1596
1738
|
keys: relatedMany(row, "keys", index).map((key) => pick(key, ["name", "starting_key", "ending_key"])),
|
|
1597
1739
|
}),
|
|
1598
1740
|
}),
|
|
1599
|
-
|
|
1741
|
+
];
|
|
1742
|
+
// --- Groups ---------------------------------------------------------------
|
|
1743
|
+
const groups = [
|
|
1600
1744
|
list({
|
|
1601
1745
|
name: "list_groups",
|
|
1602
1746
|
description: "List Groups groups with type, schedule, and member count. Archived groups are excluded unless asked for; the API cannot create a group.",
|
|
@@ -1712,7 +1856,9 @@ function tools(call, defaultPageSize) {
|
|
|
1712
1856
|
group_id: relId(row, "group") ?? null,
|
|
1713
1857
|
}),
|
|
1714
1858
|
}),
|
|
1715
|
-
|
|
1859
|
+
];
|
|
1860
|
+
// --- Check-Ins (read-only product) -----------------------------------------
|
|
1861
|
+
const checkIns = [
|
|
1716
1862
|
list({
|
|
1717
1863
|
name: "list_check_in_events",
|
|
1718
1864
|
description: "List Check-Ins events, the recurring things people check in to. Supplies eventId for event periods and check-ins.",
|
|
@@ -1806,7 +1952,9 @@ function tools(call, defaultPageSize) {
|
|
|
1806
1952
|
event_period_id: relId(row, "event_period") ?? null,
|
|
1807
1953
|
}),
|
|
1808
1954
|
}),
|
|
1809
|
-
|
|
1955
|
+
];
|
|
1956
|
+
// --- Calendar (events read-only) ------------------------------------------
|
|
1957
|
+
const calendar = [
|
|
1810
1958
|
list({
|
|
1811
1959
|
name: "list_calendar_event_instances",
|
|
1812
1960
|
description: "List what is on the Calendar in a date range: each occurrence with times and location, recurrence expanded. Events cannot be written through the API.",
|
|
@@ -1848,7 +1996,9 @@ function tools(call, defaultPageSize) {
|
|
|
1848
1996
|
event_id: relId(row, "event") ?? null,
|
|
1849
1997
|
}),
|
|
1850
1998
|
}),
|
|
1851
|
-
|
|
1999
|
+
];
|
|
2000
|
+
// --- Registrations (read-only product) ------------------------------------
|
|
2001
|
+
const registrations = [
|
|
1852
2002
|
list({
|
|
1853
2003
|
name: "list_signups",
|
|
1854
2004
|
description: "List Registrations signups (registration events) with open, close, and capacity state. Read-only: the API cannot register anyone.",
|
|
@@ -1917,7 +2067,9 @@ function tools(call, defaultPageSize) {
|
|
|
1917
2067
|
person_id: relId(row, "person") ?? null,
|
|
1918
2068
|
}),
|
|
1919
2069
|
}),
|
|
1920
|
-
|
|
2070
|
+
];
|
|
2071
|
+
// --- Giving (reads; money moves only through pco_api_mutate) ---------------
|
|
2072
|
+
const giving = [
|
|
1921
2073
|
list({
|
|
1922
2074
|
name: "list_donations",
|
|
1923
2075
|
description: "List Giving donations with their fund designations, newest first, by date, fund, or donor. Amounts are integer cents. Read-only.",
|
|
@@ -2075,7 +2227,9 @@ function tools(call, defaultPageSize) {
|
|
|
2075
2227
|
designations: designations(row, index),
|
|
2076
2228
|
}),
|
|
2077
2229
|
}),
|
|
2078
|
-
|
|
2230
|
+
];
|
|
2231
|
+
// --- Webhooks -------------------------------------------------------------
|
|
2232
|
+
const webhooks = [
|
|
2079
2233
|
{
|
|
2080
2234
|
name: "list_webhook_subscriptions",
|
|
2081
2235
|
description: "List the organization's webhook subscriptions: event name, URL, and active state. Never returns the signing secret; that needs pco_api_get.",
|
|
@@ -2104,18 +2258,117 @@ function tools(call, defaultPageSize) {
|
|
|
2104
2258
|
},
|
|
2105
2259
|
},
|
|
2106
2260
|
];
|
|
2261
|
+
return {
|
|
2262
|
+
hatches,
|
|
2263
|
+
apps: {
|
|
2264
|
+
people,
|
|
2265
|
+
services,
|
|
2266
|
+
groups,
|
|
2267
|
+
"check-ins": checkIns,
|
|
2268
|
+
calendar,
|
|
2269
|
+
registrations,
|
|
2270
|
+
giving,
|
|
2271
|
+
publishing: [],
|
|
2272
|
+
webhooks,
|
|
2273
|
+
api: [],
|
|
2274
|
+
},
|
|
2275
|
+
};
|
|
2107
2276
|
}
|
|
2108
2277
|
// ---------------------------------------------------------------------------
|
|
2109
2278
|
// Guide and construction
|
|
2110
2279
|
// ---------------------------------------------------------------------------
|
|
2111
|
-
|
|
2280
|
+
/** `a, b, and c`, the way the guide and description list products. */
|
|
2281
|
+
function series(items) {
|
|
2282
|
+
return items.length < 3 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
|
|
2283
|
+
}
|
|
2284
|
+
/** The products the description names: those with named tools for staff work. */
|
|
2285
|
+
const DESCRIBED_APPS = [
|
|
2286
|
+
"people",
|
|
2287
|
+
"services",
|
|
2288
|
+
"groups",
|
|
2289
|
+
"check-ins",
|
|
2290
|
+
"calendar",
|
|
2291
|
+
"registrations",
|
|
2292
|
+
"giving",
|
|
2293
|
+
];
|
|
2294
|
+
/** What a person id joins in each other product, for the guide's identity rule. */
|
|
2295
|
+
const PERSON_JOINS = [
|
|
2296
|
+
["services", "Services schedules"],
|
|
2297
|
+
["groups", "Groups memberships"],
|
|
2298
|
+
["giving", "Giving donations"],
|
|
2299
|
+
["check-ins", "Check-Ins"],
|
|
2300
|
+
];
|
|
2301
|
+
/**
|
|
2302
|
+
* The maintained guide for the products this connection reaches. Every
|
|
2303
|
+
* product-specific line is its own fragment, so an excluded product is
|
|
2304
|
+
* neither described, pinned, nor named by a tool the guide points to, and an
|
|
2305
|
+
* exclusion adds the line stating what the hatches refuse. With nothing
|
|
2306
|
+
* excluded, the text is the whole guide as before; the `published` fragment
|
|
2307
|
+
* is that same text without a purpose.
|
|
2308
|
+
*/
|
|
2309
|
+
function usageGuide(purpose, instructions, excluded) {
|
|
2310
|
+
const fragments = skill.fragments;
|
|
2311
|
+
const has = (app) => !excluded.has(app);
|
|
2312
|
+
const pair = (a, b, both, onlyA, onlyB) => has(a) && has(b) ? both : has(a) ? onlyA : has(b) ? onlyB : "";
|
|
2313
|
+
const apps = PLANNING_CENTER_APPS.filter(has);
|
|
2314
|
+
const joins = PERSON_JOINS.filter(([app]) => has(app)).map(([, label]) => label);
|
|
2112
2315
|
const organizationInstructions = instructions?.trim();
|
|
2113
|
-
return
|
|
2316
|
+
return [
|
|
2317
|
+
fragments.heading,
|
|
2318
|
+
apps
|
|
2319
|
+
.filter((app) => app !== "api")
|
|
2320
|
+
.map((app) => APP_TITLES[app])
|
|
2321
|
+
.join(", "),
|
|
2322
|
+
fragments.purpose,
|
|
2323
|
+
purpose,
|
|
2324
|
+
fragments.identity,
|
|
2325
|
+
joins.length > 0 ? series(joins) : "every People record",
|
|
2326
|
+
fragments.conventions,
|
|
2327
|
+
has("services") ? fragments.plans : "",
|
|
2328
|
+
fragments.writes,
|
|
2329
|
+
excluded.size === 0 ? fragments.people : fragments.people_without_lists,
|
|
2330
|
+
has("services") ? fragments.services : "",
|
|
2331
|
+
has("groups") ? fragments.groups : "",
|
|
2332
|
+
pair("check-ins", "registrations", fragments.check_ins_registrations, fragments.check_ins, fragments.registrations),
|
|
2333
|
+
has("calendar") ? fragments.calendar : "",
|
|
2334
|
+
has("giving") ? fragments.giving : "",
|
|
2335
|
+
pair("publishing", "api", fragments.publishing_api, fragments.publishing, fragments.api),
|
|
2336
|
+
fragments.hatches,
|
|
2337
|
+
apps.map((app) => `${app} ${PLANNING_CENTER_API_VERSIONS[app]}`).join(", "),
|
|
2338
|
+
fragments.pins,
|
|
2339
|
+
excluded.size === 0 ? "" : fragments.disabled,
|
|
2340
|
+
fragments.limits,
|
|
2341
|
+
organizationInstructions ? `\n## ${skill.instructionsHeading}\n\n${organizationInstructions}\n` : "",
|
|
2342
|
+
].join("");
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* `excludeApps` as a set, refusing at construction what cannot be meant
|
|
2346
|
+
* (INV-11): a product Planning Center does not have, a misspelling, or People.
|
|
2347
|
+
*/
|
|
2348
|
+
function excludedApps(value) {
|
|
2349
|
+
const excluded = new Set();
|
|
2350
|
+
if (value === undefined)
|
|
2351
|
+
return excluded;
|
|
2352
|
+
const excludable = PLANNING_CENTER_APPS.filter((app) => app !== "people").join(", ");
|
|
2353
|
+
if (!Array.isArray(value)) {
|
|
2354
|
+
throw new Error(`planningCenter() excludeApps must be an array of app names from: ${excludable}.`);
|
|
2355
|
+
}
|
|
2356
|
+
for (const entry of value) {
|
|
2357
|
+
if (entry === "people") {
|
|
2358
|
+
throw new Error('planningCenter() excludeApps cannot exclude "people": every product joins through People person ids, and the credential test reads /people/v2/me.');
|
|
2359
|
+
}
|
|
2360
|
+
if (typeof entry !== "string" || !isApp(entry)) {
|
|
2361
|
+
const lower = typeof entry === "string" ? entry.trim().toLowerCase() : "";
|
|
2362
|
+
throw new Error(`planningCenter() excludeApps has unknown app ${JSON.stringify(entry)}; expected any of ${excludable}.${isApp(lower) && lower !== "people" ? ` App names are lowercase path segments: "${lower}".` : ""}`);
|
|
2363
|
+
}
|
|
2364
|
+
excluded.add(entry);
|
|
2365
|
+
}
|
|
2366
|
+
return excluded;
|
|
2114
2367
|
}
|
|
2115
2368
|
/** The closed options planningCenter() accepts; see `assertKnownOptions`. */
|
|
2116
2369
|
const PLANNING_CENTER_OPTIONS = optionsOf()({
|
|
2117
2370
|
...PROVIDER_COMMON,
|
|
2118
|
-
...keys("userAgent", "defaultPageSize", "baseUrl"),
|
|
2371
|
+
...keys("userAgent", "defaultPageSize", "excludeApps", "baseUrl"),
|
|
2119
2372
|
});
|
|
2120
2373
|
/** A maintained Planning Center Online connection over the REST API. */
|
|
2121
2374
|
export const planningCenter = asProviderFactory({
|
|
@@ -2145,19 +2398,25 @@ function planningCenterConnector(id, options) {
|
|
|
2145
2398
|
if (!userAgent || /[\r\n]/.test(userAgent)) {
|
|
2146
2399
|
throw new Error("planningCenter() userAgent must be a non-empty single line.");
|
|
2147
2400
|
}
|
|
2401
|
+
const excluded = excludedApps(options.excludeApps);
|
|
2148
2402
|
const auth = PERSONAL_ACCESS_TOKEN;
|
|
2403
|
+
const baseUrl = options.baseUrl?.trim() || PLANNING_CENTER_API_BASE_URL;
|
|
2149
2404
|
const send = guardedFetch({
|
|
2150
2405
|
provider: "Planning Center",
|
|
2151
|
-
baseUrl
|
|
2406
|
+
baseUrl,
|
|
2152
2407
|
headers: { Accept: "application/json", "User-Agent": userAgent },
|
|
2153
2408
|
maxResponseBytes: PLANNING_CENTER_MAX_RESPONSE_BYTES,
|
|
2154
2409
|
authenticate: (ctx) => auth.headers(ctx),
|
|
2410
|
+
...(excluded.size > 0
|
|
2411
|
+
? { admit: (url, request) => admit(baseUrl, url, request, excluded) }
|
|
2412
|
+
: {}),
|
|
2155
2413
|
});
|
|
2156
|
-
const call = planningCenterCall(send, PLANNING_CENTER_API_VERSIONS);
|
|
2414
|
+
const call = planningCenterCall(send, PLANNING_CENTER_API_VERSIONS, excluded);
|
|
2415
|
+
const surface = tools(call, defaultPageSize, excluded);
|
|
2157
2416
|
return api(id, {
|
|
2158
2417
|
...(options.authScope ? { authScope: options.authScope } : {}),
|
|
2159
2418
|
title: options.title ?? "Planning Center",
|
|
2160
|
-
description: `Planning Center church management —
|
|
2419
|
+
description: `Planning Center church management — ${series(DESCRIBED_APPS.filter((app) => !excluded.has(app)).map((app) => APP_TITLES[app]))}: ${purpose}`,
|
|
2161
2420
|
credential: auth.credential,
|
|
2162
2421
|
async testCredentials(values, ctx) {
|
|
2163
2422
|
try {
|
|
@@ -2186,7 +2445,7 @@ function planningCenterConnector(id, options) {
|
|
|
2186
2445
|
},
|
|
2187
2446
|
callAdmission: options.callAdmission ?? PLANNING_CENTER_ADMISSION,
|
|
2188
2447
|
usageGuide: {
|
|
2189
|
-
content: usageGuide(purpose, options.instructions),
|
|
2448
|
+
content: usageGuide(purpose, options.instructions, excluded),
|
|
2190
2449
|
summary: "Person ids join every product, offset paging, per-product write limits, JSON:API hatches, pinned versions.",
|
|
2191
2450
|
// Deliberately not `required`. Every named tool's schema is complete
|
|
2192
2451
|
// enough to call it on its own; the guide carries the cross-product
|
|
@@ -2194,6 +2453,9 @@ function planningCenterConnector(id, options) {
|
|
|
2194
2453
|
// it leaves the named surface or hits one of them.
|
|
2195
2454
|
},
|
|
2196
2455
|
...(options.maxResultBytes !== undefined ? { maxResultBytes: options.maxResultBytes } : {}),
|
|
2197
|
-
tools:
|
|
2456
|
+
tools: [
|
|
2457
|
+
...surface.hatches,
|
|
2458
|
+
...PLANNING_CENTER_APPS.flatMap((app) => (excluded.has(app) ? [] : surface.apps[app])).filter((tool) => excluded.size === 0 || !LIST_TOOLS.has(tool.name)),
|
|
2459
|
+
],
|
|
2198
2460
|
});
|
|
2199
2461
|
}
|