@zackbart/connecta 0.28.0 → 0.29.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.
Files changed (320) hide show
  1. package/CHANGELOG.md +961 -5
  2. package/README.md +62 -39
  3. package/bin/connecta.mjs +110 -139
  4. package/bin/meta-tool-names.d.mts +2 -0
  5. package/bin/meta-tool-names.mjs +9 -0
  6. package/bin/version.mjs +3 -0
  7. package/dist/access-tokens.js +53 -63
  8. package/dist/activity-request.js +8 -0
  9. package/dist/activity.d.ts +76 -42
  10. package/dist/activity.js +110 -42
  11. package/dist/auth/clerk-sdk/client.d.ts +65 -0
  12. package/dist/auth/clerk-sdk/client.js +5 -0
  13. package/dist/auth/clerk-sdk/generated.js +1845 -0
  14. package/dist/auth/clerk-transport.js +19 -0
  15. package/dist/auth/clerk.d.ts +17 -7
  16. package/dist/auth/clerk.js +189 -107
  17. package/dist/auth/cloudflare-access.js +25 -26
  18. package/dist/auth/downstream-client-metadata.d.ts +29 -0
  19. package/dist/auth/downstream-client-metadata.js +133 -0
  20. package/dist/auth/downstream-oauth.js +1632 -1509
  21. package/dist/auth/downstream-revocation.js +106 -0
  22. package/dist/auth/oauth-token-response.js +231 -0
  23. package/dist/auth/oauth-v2-migration.js +116 -0
  24. package/dist/auth/static-oauth.js +526 -0
  25. package/dist/auth-elicitation.js +283 -0
  26. package/dist/branding.d.ts +8 -16
  27. package/dist/branding.js +26 -59
  28. package/dist/byte-read-response.js +39 -0
  29. package/dist/call-admission.js +12 -16
  30. package/dist/catalog-cache.js +739 -0
  31. package/dist/catalog-drift.d.ts +50 -15
  32. package/dist/catalog-drift.js +365 -128
  33. package/dist/catalog-fingerprint.js +2 -4
  34. package/dist/catalog-freshness.js +13 -0
  35. package/dist/catalog-limits.js +2 -2
  36. package/dist/catalog-service.js +368 -224
  37. package/dist/catalog.js +43 -84
  38. package/dist/concurrency.js +3 -4
  39. package/dist/config-defaults.d.ts +39 -0
  40. package/dist/config-defaults.js +46 -0
  41. package/dist/config-schema.d.ts +181 -0
  42. package/dist/config-schema.js +435 -0
  43. package/dist/config-value-sources.js +102 -0
  44. package/dist/config.d.ts +631 -0
  45. package/dist/config.js +503 -0
  46. package/dist/connector-access.d.ts +1 -0
  47. package/dist/connector-access.js +13 -15
  48. package/dist/connector-caller.d.ts +8 -0
  49. package/dist/connector-caller.js +15 -6
  50. package/dist/connector-scope.js +1 -1
  51. package/dist/connectors/api-connector.d.ts +220 -0
  52. package/dist/connectors/api-connector.js +214 -0
  53. package/dist/connectors/api.d.ts +8 -71
  54. package/dist/connectors/api.js +13 -99
  55. package/dist/connectors/guarded-fetch.d.ts +144 -0
  56. package/dist/connectors/guarded-fetch.js +108 -25
  57. package/dist/connectors/negotiation-cache.js +46 -0
  58. package/dist/connectors/option-shapes.js +27 -0
  59. package/dist/connectors/remote-mcp.d.ts +30 -4
  60. package/dist/connectors/remote-mcp.js +1780 -351
  61. package/dist/connectors/resource-uri.js +335 -0
  62. package/dist/credential-contract.d.ts +10 -0
  63. package/dist/credential-rules.js +1 -3
  64. package/dist/credential-url.js +108 -0
  65. package/dist/credentials.d.ts +6 -1
  66. package/dist/credentials.js +27 -18
  67. package/dist/d1.d.ts +130 -0
  68. package/dist/d1.js +290 -0
  69. package/dist/describe-config.d.ts +165 -0
  70. package/dist/describe-config.js +315 -0
  71. package/dist/described.js +106 -0
  72. package/dist/downstream-input-context.js +119 -0
  73. package/dist/downstream-input.js +323 -0
  74. package/dist/errors.d.ts +97 -20
  75. package/dist/errors.js +158 -80
  76. package/dist/execute.js +453 -305
  77. package/dist/executor-admission.d.ts +1 -0
  78. package/dist/executor-admission.js +11 -15
  79. package/dist/executor-contract.d.ts +20 -0
  80. package/dist/executor-contract.js +48 -0
  81. package/dist/executor-result.js +24 -9
  82. package/dist/executors/quickjs-child.js +3 -4
  83. package/dist/executors/quickjs-runtime.js +147 -59
  84. package/dist/executors/quickjs.js +29 -35
  85. package/dist/favicon.js +1 -2
  86. package/dist/fix-prompt.js +1 -3
  87. package/dist/guest-runtime.js +188 -0
  88. package/dist/guest-types.d.ts +176 -0
  89. package/dist/guest-types.js +1 -0
  90. package/dist/html-security.js +24 -0
  91. package/dist/identity.js +1 -3
  92. package/dist/inbound-credential.js +52 -0
  93. package/dist/index.d.ts +25 -257
  94. package/dist/index.js +65 -478
  95. package/dist/invocation-auth.js +69 -0
  96. package/dist/invocation.js +230 -73
  97. package/dist/json-schema.d.ts +1 -1
  98. package/dist/mcp-client-context.js +35 -0
  99. package/dist/mcp-result.js +11 -7
  100. package/dist/meta-output.js +76 -0
  101. package/dist/meta-tool-names.d.ts +2 -0
  102. package/dist/meta-tool-names.js +3 -0
  103. package/dist/meta-tools.js +285 -209
  104. package/dist/module-contracts.d.ts +8 -23
  105. package/dist/node.d.ts +0 -2
  106. package/dist/node.js +2 -2
  107. package/dist/oauth-callback-outcome.js +1 -3
  108. package/dist/oauth-handoff.js +117 -0
  109. package/dist/oauth-partition.js +17 -0
  110. package/dist/oauth-sealing.js +20 -0
  111. package/dist/operator-record.d.ts +137 -0
  112. package/dist/operator-record.js +420 -0
  113. package/dist/operator-ui/assets.js +31 -0
  114. package/dist/operator-ui/contract.d.ts +72 -0
  115. package/dist/operator-ui/contract.js +1 -0
  116. package/dist/operator-ui/fix-prompts.d.ts +18 -0
  117. package/dist/operator-ui/generated.js +5 -4
  118. package/dist/operator-ui/model.d.ts +12 -87
  119. package/dist/operator-ui/model.js +2 -9
  120. package/dist/operator-ui/setup-commands.js +1 -3
  121. package/dist/operator-ui/view.d.ts +323 -0
  122. package/dist/operator-ui/view.js +62 -189
  123. package/dist/page-styles.js +2 -3
  124. package/dist/program-source.js +14 -2
  125. package/dist/{exempt-writes.js → program-writes.js} +55 -18
  126. package/dist/provider.d.ts +142 -0
  127. package/dist/provider.js +187 -0
  128. package/dist/providers/_shared/google/result-size.js +57 -0
  129. package/dist/providers/_shared/google/service-account.d.ts +58 -0
  130. package/dist/providers/_shared/google/service-account.js +500 -0
  131. package/dist/providers/_shared/google/workspace.d.ts +276 -0
  132. package/dist/providers/_shared/google/workspace.js +518 -0
  133. package/dist/providers/basecamp/index.d.ts +43 -0
  134. package/dist/providers/basecamp/index.js +997 -0
  135. package/dist/providers/basecamp/skill.generated.js +9 -0
  136. package/dist/providers/breeze/index.d.ts +34 -0
  137. package/dist/providers/breeze/index.js +1446 -0
  138. package/dist/providers/breeze/skill.generated.js +9 -0
  139. package/dist/providers/ccb/index.d.ts +103 -0
  140. package/dist/providers/ccb/index.js +1725 -0
  141. package/dist/providers/ccb/skill.generated.js +9 -0
  142. package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
  143. package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
  144. package/dist/providers/cloudflare/skill.generated.js +10 -0
  145. package/dist/providers/docs/index.d.ts +54 -0
  146. package/dist/providers/docs/index.js +1237 -0
  147. package/dist/providers/docs/skill.generated.js +9 -0
  148. package/dist/providers/drive/index.d.ts +53 -0
  149. package/dist/providers/drive/index.js +1523 -0
  150. package/dist/providers/drive/skill.generated.js +9 -0
  151. package/dist/providers/forms/index.d.ts +54 -0
  152. package/dist/providers/forms/index.js +1217 -0
  153. package/dist/providers/forms/skill.generated.js +11 -0
  154. package/dist/providers/github/auth.d.ts +32 -0
  155. package/dist/providers/github/auth.js +361 -0
  156. package/dist/providers/github/hosted.js +218 -0
  157. package/dist/providers/github/index.d.ts +13 -0
  158. package/dist/providers/github/index.js +140 -0
  159. package/dist/providers/github/key.js +100 -0
  160. package/dist/providers/github/rest.js +321 -0
  161. package/dist/providers/github/scope.d.ts +29 -0
  162. package/dist/providers/github/scope.js +124 -0
  163. package/dist/providers/github/skill.generated.js +8 -0
  164. package/dist/providers/gmail/index.d.ts +51 -0
  165. package/dist/providers/gmail/index.js +1436 -0
  166. package/dist/providers/gmail/skill.generated.js +9 -0
  167. package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
  168. package/dist/providers/linear/index.js +352 -0
  169. package/dist/providers/linear/skill.generated.js +8 -0
  170. package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
  171. package/dist/providers/mixpanel/index.js +416 -0
  172. package/dist/providers/mixpanel/skill.generated.js +9 -0
  173. package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
  174. package/dist/providers/{notion.js → notion/index.js} +226 -239
  175. package/dist/providers/notion/skill.generated.js +10 -0
  176. package/dist/providers/overflow/index.d.ts +60 -0
  177. package/dist/providers/overflow/index.js +1535 -0
  178. package/dist/providers/overflow/skill.generated.js +8 -0
  179. package/dist/providers/planning-center/index.d.ts +59 -0
  180. package/dist/providers/planning-center/index.js +2199 -0
  181. package/dist/providers/planning-center/skill.generated.js +10 -0
  182. package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
  183. package/dist/providers/revenuecat/index.js +557 -0
  184. package/dist/providers/revenuecat/skill.generated.js +11 -0
  185. package/dist/providers/sheets/index.d.ts +54 -0
  186. package/dist/providers/sheets/index.js +1237 -0
  187. package/dist/providers/sheets/skill.generated.js +10 -0
  188. package/dist/providers/slides/index.d.ts +53 -0
  189. package/dist/providers/slides/index.js +2645 -0
  190. package/dist/providers/slides/skill.generated.js +9 -0
  191. package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
  192. package/dist/providers/stripe/index.js +266 -0
  193. package/dist/providers/stripe/skill.generated.js +11 -0
  194. package/dist/providers/tithely/index.d.ts +49 -0
  195. package/dist/providers/tithely/index.js +1003 -0
  196. package/dist/providers/tithely/skill.generated.js +8 -0
  197. package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
  198. package/dist/providers/vercel/index.js +1038 -0
  199. package/dist/providers/vercel/mcp-ownership.js +212 -0
  200. package/dist/providers/vercel/skill.generated.js +11 -0
  201. package/dist/registry.d.ts +108 -171
  202. package/dist/registry.js +513 -925
  203. package/dist/request-state.js +21 -0
  204. package/dist/resource-read.js +100 -0
  205. package/dist/result-shapes.js +17 -49
  206. package/dist/retired-toolkits.js +1 -1
  207. package/dist/routes/access-tokens.js +3 -6
  208. package/dist/routes/activity-context.js +21 -0
  209. package/dist/routes/activity-disclosure.js +40 -0
  210. package/dist/routes/activity.js +59 -21
  211. package/dist/routes/actor-labels.js +3 -50
  212. package/dist/routes/connect.js +221 -0
  213. package/dist/routes/credentials.js +15 -27
  214. package/dist/routes/mcp.js +356 -130
  215. package/dist/routes/oauth-client-metadata.js +15 -0
  216. package/dist/routes/oauth-management.js +45 -107
  217. package/dist/routes/oauth.js +136 -57
  218. package/dist/routes/operator.js +3 -3
  219. package/dist/routes/shared.d.ts +25 -64
  220. package/dist/routes/shared.js +160 -80
  221. package/dist/routes/skills.js +30 -0
  222. package/dist/routes/ui-config.js +225 -0
  223. package/dist/routes/ui-data.js +40 -35
  224. package/dist/routes/ui.js +66 -56
  225. package/dist/runtime/admission.js +4 -2
  226. package/dist/runtime/call-admission.js +3 -1
  227. package/dist/runtime/connector-scope.js +1 -4
  228. package/dist/runtime/run.js +10 -39
  229. package/dist/runtime/services.js +12 -117
  230. package/dist/runtime/shared-read.js +1 -3
  231. package/dist/runtime/storage.js +8 -1
  232. package/dist/sent-secrets.d.ts +52 -0
  233. package/dist/sent-secrets.js +474 -0
  234. package/dist/server.js +28 -71
  235. package/dist/skills.js +406 -174
  236. package/dist/sqlite.d.ts +48 -0
  237. package/dist/sqlite.js +166 -0
  238. package/dist/storage/keys.js +428 -0
  239. package/dist/storage/memory.js +7 -0
  240. package/dist/storage/sql.d.ts +66 -0
  241. package/dist/storage/sql.js +534 -0
  242. package/dist/timeout.js +4 -2
  243. package/dist/tool-name.js +17 -0
  244. package/dist/tool-safety.d.ts +9 -35
  245. package/dist/tool-safety.js +20 -40
  246. package/dist/types.d.ts +320 -54
  247. package/dist/types.js +0 -1
  248. package/dist/ui.d.ts +7 -19
  249. package/dist/ui.js +76 -90
  250. package/dist/url-safety.js +3 -6
  251. package/dist/usage-guide.js +137 -0
  252. package/dist/validate.d.ts +7 -0
  253. package/dist/validate.js +309 -47
  254. package/dist/version.d.ts +1 -7
  255. package/dist/version.js +3 -7
  256. package/dist/worker.d.ts +11 -0
  257. package/dist/worker.js +434 -0
  258. package/documentation/architecture.md +665 -230
  259. package/documentation/auth.md +962 -458
  260. package/documentation/code-mode.md +501 -312
  261. package/documentation/meta-tools.md +449 -200
  262. package/documentation/operator-tests.md +93 -0
  263. package/documentation/provider-migration-0.29.md +60 -0
  264. package/examples/worker/AGENTS.md +34 -12
  265. package/examples/worker/README.md +286 -255
  266. package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
  267. package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
  268. package/examples/worker/src/connecta.config.ts +151 -0
  269. package/examples/worker/src/index.ts +14 -197
  270. package/examples/worker/wrangler.jsonc +15 -35
  271. package/package.json +125 -35
  272. package/templates/node/.dockerignore +1 -2
  273. package/templates/node/.env.example +20 -21
  274. package/templates/node/AGENTS.md +21 -17
  275. package/templates/node/Dockerfile +3 -5
  276. package/templates/node/README.md +133 -58
  277. package/templates/node/docker-compose.yml +11 -12
  278. package/templates/node/package.json +4 -2
  279. package/templates/node/src/connecta.config.ts +119 -0
  280. package/templates/node/src/index.ts +9 -154
  281. package/templates/node/src/provision-token.ts +17 -0
  282. package/AGENTS.md +0 -170
  283. package/dist/artifacts/connector.d.ts +0 -58
  284. package/dist/artifacts/connector.js +0 -730
  285. package/dist/artifacts/document.js +0 -86
  286. package/dist/artifacts/guide.js +0 -140
  287. package/dist/artifacts/html-scan.js +0 -329
  288. package/dist/artifacts/json.js +0 -78
  289. package/dist/artifacts/kv-store.d.ts +0 -20
  290. package/dist/artifacts/kv-store.js +0 -140
  291. package/dist/artifacts/markdown.js +0 -478
  292. package/dist/artifacts/module.d.ts +0 -37
  293. package/dist/artifacts/module.js +0 -66
  294. package/dist/artifacts/operations.d.ts +0 -197
  295. package/dist/artifacts/operations.js +0 -1002
  296. package/dist/artifacts/refresh.d.ts +0 -55
  297. package/dist/artifacts/refresh.js +0 -236
  298. package/dist/artifacts/routes.js +0 -206
  299. package/dist/artifacts/types.d.ts +0 -214
  300. package/dist/artifacts/types.js +0 -22
  301. package/dist/artifacts/validate.d.ts +0 -62
  302. package/dist/artifacts/validate.js +0 -651
  303. package/dist/artifacts.d.ts +0 -6
  304. package/dist/artifacts.js +0 -9
  305. package/dist/auth/bearer.d.ts +0 -11
  306. package/dist/auth/bearer.js +0 -50
  307. package/dist/providers/linear.js +0 -217
  308. package/dist/providers/mixpanel.js +0 -247
  309. package/dist/providers/revenuecat.js +0 -317
  310. package/dist/providers/stripe.js +0 -253
  311. package/dist/providers/vercel.js +0 -1253
  312. package/dist/storage/file.d.ts +0 -15
  313. package/dist/storage/file.js +0 -455
  314. package/ethos.md +0 -93
  315. package/examples/worker/src/cloudflare-kv.ts +0 -43
  316. package/examples/worker/src/d1-activity-row.ts +0 -155
  317. package/examples/worker/src/d1-activity.ts +0 -147
  318. package/examples/worker/src/d1-storage.ts +0 -138
  319. package/examples/worker/src/r2-artifact-blobs.ts +0 -29
  320. package/templates/node/src/file-activity.ts +0 -182
@@ -0,0 +1,1725 @@
1
+ import { skill } from "./skill.generated.js";
2
+ /**
3
+ * Church Community Builder — sold today as Pushpay ChMS — through its v2 JSON
4
+ * REST API, hand-written over `api()`'s downstream OAuth grant.
5
+ *
6
+ * Why `ccb()` and not `pushpayChms()`: every wire contract this file depends
7
+ * on says CCB — the `api.ccbchurch.com` host, the
8
+ * `application/vnd.ccbchurch.v2+json` media type, the OAuth hosts — and so do
9
+ * the churches that use it. "Pushpay" also names a separate giving platform
10
+ * with its own API, so a `pushpay()` export would collide with the provider
11
+ * that one day wraps it. The title says both names, so neither reader guesses.
12
+ *
13
+ * Considered and rejected: Pushpay hosts ReadMe "docs MCP" servers
14
+ * (https://pushpay-chmsv2.readme.io/mcp). They search the documentation and
15
+ * proxy raw HAR-shaped requests; they own no OAuth grant, project nothing,
16
+ * classify nothing, and would hand an agent a generic request tool whose
17
+ * safety class it chooses per call. This connector is the opposite trade:
18
+ * named reads with projections, and raw access split by safety.
19
+ *
20
+ * Out of scope on purpose: transaction and batch listing exists only in the
21
+ * legacy v1 XML API (`<church>.ccbchurch.com/api.php`, per-church API users,
22
+ * a daily cap). v2 exposes giving only as per-person and per-family metrics,
23
+ * pledges, scheduled gifts, and financial settings, and that is all this
24
+ * connector reaches.
25
+ *
26
+ * Drift is handled the Vercel way: `src/providers/ccb/drift.json` records
27
+ * every endpoint a named tool touches, and
28
+ * `npm run providers:check -- --provider ccb` compares those rows with the
29
+ * published, credential-free OpenAPI document at
30
+ * https://docs.pushpay.io/chms-v2/openapi/chms-api-specs.json.
31
+ */
32
+ import { api } from "../../connectors/api.js";
33
+ import { defined } from "../../connectors/api-connector.js";
34
+ import { guardedFetch, oauthBearer, retryAfterMs, } from "../../connectors/guarded-fetch.js";
35
+ import { ConnectorCallError } from "../../errors.js";
36
+ import { keys, optionsOf } from "../../config-schema.js";
37
+ import { CALL_ADMISSION } from "../../connectors/option-shapes.js";
38
+ import { asProviderFactory } from "../../provider.js";
39
+ /** CCB's v2 media type. The API and its token endpoint both require it. */
40
+ export const CCB_MEDIA_TYPE = "application/vnd.ccbchurch.v2+json";
41
+ /**
42
+ * The two published environments. Credentials do not cross: sandbox clients
43
+ * work only against the sandbox hosts and production clients only against
44
+ * production, so the environment is part of the client, not a preference.
45
+ */
46
+ export const CCB_ENVIRONMENTS = {
47
+ production: {
48
+ apiOrigin: "https://api.ccbchurch.com",
49
+ authorizationEndpoint: "https://oauth.ccbchurch.com/oauth/authorize",
50
+ },
51
+ sandbox: {
52
+ apiOrigin: "https://api-beta.ccbchurch.com",
53
+ authorizationEndpoint: "https://beta-oauth.ccbchurch.com/oauth/authorize",
54
+ },
55
+ };
56
+ /**
57
+ * The read scopes the named tools and the search hatch need, from the
58
+ * `security` requirements of the operations they call (spec revision recorded
59
+ * in the drift manifest). `read:campuses` and `read:church` are there so the
60
+ * ids every projection carries — campus, membership type — can be resolved
61
+ * through `ccb_api_get`. Request these on Pushpay's API access form.
62
+ */
63
+ export const CCB_READ_SCOPES = [
64
+ "read:advanced_searches",
65
+ "read:campuses",
66
+ "read:church",
67
+ "read:event_attendance",
68
+ "read:events",
69
+ "read:forms",
70
+ "read:group_members",
71
+ "read:groups",
72
+ "read:individual_groups",
73
+ "read:individual_notes",
74
+ "read:individual_pledges",
75
+ "read:individuals",
76
+ "read:individuals_metrics",
77
+ "read:process_queues",
78
+ "read:queue_individuals",
79
+ "read:scheduling",
80
+ ];
81
+ /**
82
+ * What `access: "read-write"` adds: the writes of the everyday staff jobs —
83
+ * profiles, groups, events and attendance, form responses, notes, process
84
+ * queues, scheduling. No `delete:` scope, nothing financial, no background
85
+ * checks, and no privacy settings: those are deliberate, so they are only
86
+ * ever requested through an explicit `scopes` list.
87
+ */
88
+ export const CCB_WRITE_SCOPES = [
89
+ "write:event_attendance",
90
+ "write:events",
91
+ "write:form_responses",
92
+ "write:group_members",
93
+ "write:groups",
94
+ "write:individual_groups",
95
+ "write:individual_notes",
96
+ "write:individuals",
97
+ "write:notes",
98
+ "write:queue_individuals",
99
+ "write:scheduling",
100
+ ];
101
+ const SCOPE_SHAPE = /^(read|write|delete):[a-z_]+$/;
102
+ const PER_PAGE_VALUES = [25, 50, 75, 100];
103
+ /** CCB's own minimum, which is also the cheapest honest first read. */
104
+ const DEFAULT_PER_PAGE = 25;
105
+ /**
106
+ * A ceiling on absurdity rather than a budget: at 100 rows per page the
107
+ * fattest documented list (individuals with addresses) stays far below it.
108
+ */
109
+ const CCB_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
110
+ /**
111
+ * CCB meters the v2 API per endpoint and per API client: one request a second
112
+ * sustained with a burst of 60 (docs.pushpay.io/chms-v2/docs/general). Three
113
+ * endpoints are stricter and allow no burst — `/individuals/{id}` at one a
114
+ * second, `/scheduling/categories/{id}/schedules` at one per two seconds, and
115
+ * `/search/individuals/results` at one per five.
116
+ *
117
+ * What ships is one rolling window of 60 calls per 60 seconds, partitioned by
118
+ * endpoint (method plus path template), which is the documented default
119
+ * transcribed as faithfully as one rule can: it never admits more than the
120
+ * token bucket would in any minute, because a bucket of 60 refilling at one a
121
+ * second can pass up to 120 in a minute and the window passes 60. Partitioning
122
+ * matters — a single connector-wide window would throttle a healthy program
123
+ * that touches ten endpoints to a tenth of what CCB allows.
124
+ *
125
+ * What it cannot express is the three no-burst overrides: this release
126
+ * enforces one rule, and a rule has one budget for every partition. Those are
127
+ * left to CCB's own 429, which carries `retry-after` and maps to a
128
+ * `rate_limited` failure with that wait; the guide says to space those calls.
129
+ * No `maxConcurrency` is declared: per-endpoint concurrency would not keep a
130
+ * fast loop under one a second, and the window already bounds the burst.
131
+ *
132
+ * Per runtime, and per API client only as far as one runtime is the whole
133
+ * client: other deployments, isolates, or integrations using the same client
134
+ * spend from CCB's bucket without spending from this window.
135
+ */
136
+ const CCB_ADMISSION = {
137
+ rules: [
138
+ {
139
+ budget: { kind: "rolling-window", maxCalls: 60, windowMs: 60_000 },
140
+ partitionKey: ({ toolName, args }) => endpointPartition(toolName, args),
141
+ },
142
+ ],
143
+ };
144
+ function asRecord(value) {
145
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
146
+ }
147
+ function asArray(value) {
148
+ return Array.isArray(value) ? value : [];
149
+ }
150
+ function compact(value) {
151
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined && entry !== null));
152
+ }
153
+ /** An object that is empty after compaction reads as absent. */
154
+ function present(value) {
155
+ return Object.keys(value).length > 0 ? value : undefined;
156
+ }
157
+ // ---------------------------------------------------------------------------
158
+ // Admission partitions
159
+ // ---------------------------------------------------------------------------
160
+ /** The method and path template each named tool calls. */
161
+ const NAMED_ENDPOINTS = {
162
+ get_me: "GET /me",
163
+ list_individuals: "GET /individuals",
164
+ get_individual: "GET /individuals/{id}",
165
+ list_families: "GET /families",
166
+ get_family: "GET /families/{id}",
167
+ list_groups: "GET /groups",
168
+ get_group: "GET /groups/{id}",
169
+ list_group_members: "GET /groups/{id}/members",
170
+ list_individual_groups: "GET /individuals/{id}/groups",
171
+ list_events: "GET /events",
172
+ get_event: "GET /events/{id}",
173
+ get_event_attendance: "GET /events/{id}/attendance/{occurrence}",
174
+ list_event_attendees: "GET /events/{id}/attendance/{occurrence}/attendees",
175
+ list_forms: "GET /forms",
176
+ list_form_responses: "GET /forms/{id}/responses",
177
+ list_processes: "GET /processes",
178
+ list_queues: "GET /queues",
179
+ list_queue_individuals: "GET /queues_individuals",
180
+ list_scheduling_categories: "GET /scheduling/categories",
181
+ list_schedules: "GET /scheduling/categories/{id}/schedules",
182
+ list_individual_assignments: "GET /individuals/{id}/assignments",
183
+ list_individual_notes: "GET /individuals/{id}/notes",
184
+ get_giving_metrics: (args) => args["familyId"] !== undefined ? "GET /families/{id}/metrics/giving" : "GET /individuals/{id}/metrics/giving",
185
+ list_individual_pledges: "GET /individuals/{id}/pledges",
186
+ ccb_api_search: (args) => `POST /search/${typeof args["domain"] === "string" ? args["domain"] : "?"}/results`,
187
+ };
188
+ /** `/individuals/123/notes` → `/individuals/{id}/notes`; the key never holds an id. */
189
+ function pathTemplate(path) {
190
+ return path
191
+ .split("/")
192
+ .map((segment) => (/^\d+$/.test(segment) ? "{id}" : segment))
193
+ .join("/");
194
+ }
195
+ const MAX_PARTITION_KEY_CHARS = 120;
196
+ /**
197
+ * The CCB endpoint one call will spend from, as a bounded, id-free key. Runs
198
+ * before argument validation, so it reads arguments defensively and never
199
+ * throws: a malformed hatch path falls into one shared partition and is then
200
+ * refused by the handler.
201
+ */
202
+ function endpointPartition(toolName, raw) {
203
+ const args = asRecord(raw);
204
+ const named = NAMED_ENDPOINTS[toolName];
205
+ let key;
206
+ if (typeof named === "string")
207
+ key = named;
208
+ else if (named)
209
+ key = named(args);
210
+ else {
211
+ const method = toolName === "ccb_api_get" ? "GET" : typeof args["method"] === "string" ? args["method"] : "?";
212
+ const path = typeof args["path"] === "string" ? args["path"] : "";
213
+ key = `${method} ${pathTemplate(path)}`;
214
+ }
215
+ // ASCII-only after templating in every realistic case; the slice keeps an
216
+ // absurd path inside the admission controller's 128-byte key limit anyway.
217
+ return [...key]
218
+ .filter((c) => c.charCodeAt(0) < 128)
219
+ .join("")
220
+ .slice(0, MAX_PARTITION_KEY_CHARS);
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // Transport and typed failures
224
+ // ---------------------------------------------------------------------------
225
+ function errorDetail(payload, status) {
226
+ const root = asRecord(payload);
227
+ const parts = [];
228
+ for (const key of ["message", "error_description", "error"]) {
229
+ const value = root[key];
230
+ if (typeof value === "string" && value.trim()) {
231
+ parts.push(value.trim());
232
+ break;
233
+ }
234
+ }
235
+ for (const item of asArray(root["errors"]).slice(0, 5)) {
236
+ if (typeof item === "string" && item.trim())
237
+ parts.push(item.trim());
238
+ else {
239
+ const message = asRecord(item)["message"];
240
+ if (typeof message === "string" && message.trim())
241
+ parts.push(message.trim());
242
+ }
243
+ }
244
+ const detail = parts.join("; ").slice(0, 500);
245
+ return detail ? `CCB HTTP ${status}: ${detail}` : `CCB returned HTTP ${status}.`;
246
+ }
247
+ /** The wait CCB states: `retry-after`, else the `x-ratelimit-reset` epoch. */
248
+ function rateLimitWaitMs(headers, payload) {
249
+ const stated = retryAfterMs(headers);
250
+ if (stated !== undefined)
251
+ return stated;
252
+ const reset = Number(headers.get("x-ratelimit-reset"));
253
+ if (headers.get("x-ratelimit-reset") && Number.isFinite(reset)) {
254
+ return Math.max(0, Math.trunc(reset * 1_000 - Date.now()));
255
+ }
256
+ const body = Number(asRecord(payload)["retry_after"]);
257
+ return Number.isFinite(body) && body >= 0 ? Math.trunc(body * 1_000) : undefined;
258
+ }
259
+ /**
260
+ * Map a CCB failure by what the caller does next.
261
+ *
262
+ * A 401 rarely reaches here: `ctx.oauth.fetch` answers one with a refresh and
263
+ * a replay and throws `auth_required` itself when that fails. A 403 is not
264
+ * `auth_required`, because re-consenting with the same scopes cannot fix it:
265
+ * either the client was not granted the scope (an operator widens `scopes`
266
+ * and restarts authorization) or, in Identity Auth, the signed-in person
267
+ * lacks the permission in CCB. CCB answers a permission gap with 403 and an
268
+ * absence with 404, so 404 is a genuine `not_found`.
269
+ */
270
+ function ccbFailure(status, headers, payload) {
271
+ const detail = errorDetail(payload, status);
272
+ if (status === 429) {
273
+ const wait = rateLimitWaitMs(headers, payload);
274
+ return new ConnectorCallError("rate_limited", `${detail} CCB limits each endpoint per API client; wait before calling this endpoint again.`, wait === undefined ? {} : { retryAfterMs: wait });
275
+ }
276
+ if (status === 401) {
277
+ return new ConnectorCallError("auth_required", `${detail} CCB rejected the connection's access token. Call authorize_connector for this connector, then retry.`);
278
+ }
279
+ if (status === 403) {
280
+ return new ConnectorCallError("provider_permission_denied", `${detail} The grant lacks this endpoint's scope, or (Identity Auth) the signed-in person lacks the permission in CCB. Ask the CCB administrator to grant this endpoint's scope or the signed-in person's permission. Re-authorizing alone will not fix it.`, { retryable: false });
281
+ }
282
+ if (status === 404) {
283
+ return new ConnectorCallError("not_found", `${detail} Re-read the id from its list tool; CCB reports a permission gap as 403, so this record does not exist.`);
284
+ }
285
+ if (status === 409)
286
+ return new ConnectorCallError("conflict", detail);
287
+ if (status === 400 || status === 412 || status === 422) {
288
+ return new ConnectorCallError("invalid_args", detail);
289
+ }
290
+ if (status >= 500) {
291
+ const wait = retryAfterMs(headers);
292
+ return new ConnectorCallError("unavailable", `${detail} CCB is failing upstream.`, wait === undefined ? {} : { retryAfterMs: wait });
293
+ }
294
+ return new ConnectorCallError("connector_call_failed", detail, { retryable: false });
295
+ }
296
+ function ccbTransport(apiOrigin) {
297
+ return guardedFetch({
298
+ provider: "CCB",
299
+ baseUrl: apiOrigin,
300
+ headers: { Accept: CCB_MEDIA_TYPE },
301
+ maxResponseBytes: CCB_MAX_RESPONSE_BYTES,
302
+ ...oauthBearer("CCB"),
303
+ });
304
+ }
305
+ async function callCcb(send, request, ctx) {
306
+ return await send(request, ctx, async (response) => {
307
+ const parsed = await response.jsonResult();
308
+ if (!response.ok) {
309
+ throw ccbFailure(response.status, response.headers, "value" in parsed ? parsed.value : undefined);
310
+ }
311
+ if (!("value" in parsed)) {
312
+ throw new ConnectorCallError("connector_call_failed", "CCB returned a successful response that is not JSON.", {
313
+ retryable: false,
314
+ });
315
+ }
316
+ return { body: parsed.value, headers: response.headers };
317
+ });
318
+ }
319
+ // ---------------------------------------------------------------------------
320
+ // Pagination
321
+ // ---------------------------------------------------------------------------
322
+ function headerInt(headers, name) {
323
+ const raw = headers.get(name);
324
+ if (raw === null || raw.trim() === "")
325
+ return undefined;
326
+ const value = Number(raw);
327
+ return Number.isInteger(value) && value >= 0 ? value : undefined;
328
+ }
329
+ /**
330
+ * One branch for every list: `hasMore`, from `x-next-page` when CCB sends it
331
+ * (empty on the last page), else `x-page` against `x-total-pages`, else — a
332
+ * list that sent neither — a full page is read as "maybe more".
333
+ */
334
+ function pageInfo(headers, page, perPage, rows) {
335
+ const current = headerInt(headers, "x-page") ?? page;
336
+ const totalPages = headerInt(headers, "x-total-pages");
337
+ let nextPage;
338
+ if (headers.has("x-next-page"))
339
+ nextPage = headerInt(headers, "x-next-page") ?? null;
340
+ else if (totalPages !== undefined)
341
+ nextPage = current < totalPages ? current + 1 : null;
342
+ else
343
+ nextPage = rows >= perPage ? current + 1 : null;
344
+ return {
345
+ hasMore: nextPage !== null,
346
+ nextPage,
347
+ total: headerInt(headers, "x-total") ?? null,
348
+ };
349
+ }
350
+ // ---------------------------------------------------------------------------
351
+ // Projections
352
+ //
353
+ // One mechanical rule: CCB's snake_case becomes camelCase, nested references
354
+ // flatten to an id and a name, and permission flags (`actions`), images,
355
+ // creator and modifier records are dropped everywhere. Sensitive fields are
356
+ // dropped too — allergies, giving numbers, envelope ids, giving dates and
357
+ // statement dates, prayer requests and meeting notes on attendance, payment
358
+ // details on form responses. `raw: true` returns CCB's row untouched.
359
+ // ---------------------------------------------------------------------------
360
+ function phones(value) {
361
+ const phone = asRecord(value);
362
+ return present(compact({
363
+ mobile: phone["mobile"] || undefined,
364
+ home: phone["home"] || undefined,
365
+ work: phone["work"] || undefined,
366
+ }));
367
+ }
368
+ function address(value) {
369
+ const row = asRecord(value);
370
+ return present(compact({
371
+ street: row["street"] || undefined,
372
+ city: row["city"] || undefined,
373
+ state: row["state"] || undefined,
374
+ zip: row["zip"] || undefined,
375
+ country: row["country_iso"] || undefined,
376
+ }));
377
+ }
378
+ function projectPerson(value) {
379
+ const row = asRecord(value);
380
+ return compact({
381
+ id: row["id"],
382
+ name: row["name"],
383
+ firstName: row["first_name"],
384
+ lastName: row["last_name"],
385
+ email: row["email"] || undefined,
386
+ phones: phones(row["phone"]),
387
+ birthday: row["birthday"],
388
+ familyId: row["family_id"],
389
+ familyPosition: row["family_position"],
390
+ campusId: row["campus_id"],
391
+ membershipTypeId: row["membership_type_id"],
392
+ active: row["active"],
393
+ deceased: row["deceased"],
394
+ });
395
+ }
396
+ function projectIndividual(value) {
397
+ const row = asRecord(value);
398
+ const addresses = asRecord(row["addresses"]);
399
+ return compact({
400
+ ...projectPerson(row),
401
+ gender: row["gender"],
402
+ maritalStatus: row["marital_status"],
403
+ homeAddress: address(addresses["home"]),
404
+ mailingAddress: address(addresses["mailing"]),
405
+ campusName: row["campus_name"],
406
+ membershipTypeName: row["membership_type_name"],
407
+ membershipDate: row["membership_date"],
408
+ schoolGrade: row["school_grade"],
409
+ baptized: row["baptized"],
410
+ baptizedDate: row["baptized_date"],
411
+ lastAttendedDate: row["last_attended_date"],
412
+ limitedAccessUser: row["limited_access_user"],
413
+ listed: row["listed"],
414
+ created: row["created"],
415
+ modified: row["modified"],
416
+ customFields: present(Object.fromEntries(asArray(row["custom_fields"])
417
+ .map(asRecord)
418
+ .filter((field) => field["id"] !== undefined)
419
+ .map((field) => [String(field["id"]), field["value"]]))),
420
+ });
421
+ }
422
+ function projectFamily(value) {
423
+ const row = asRecord(value);
424
+ return compact({
425
+ id: row["id"],
426
+ lastName: row["last_name"],
427
+ address: address(row["address"]),
428
+ members: Array.isArray(row["members"]) ? row["members"].map(projectPerson) : undefined,
429
+ });
430
+ }
431
+ function ref(value) {
432
+ const row = asRecord(value);
433
+ return row["id"] === undefined && row["name"] === undefined
434
+ ? undefined
435
+ : compact({ id: row["id"], name: row["name"] });
436
+ }
437
+ function projectGroup(value) {
438
+ const row = asRecord(value);
439
+ return compact({
440
+ id: row["id"],
441
+ name: row["name"],
442
+ campusId: row["campus_id"],
443
+ inactive: row["inactive"],
444
+ groupType: asRecord(row["group_type"])["name"],
445
+ interactionType: row["interaction_type"],
446
+ membershipType: row["membership_type"],
447
+ mainLeader: ref(row["main_leader"]),
448
+ departmentId: asRecord(row["department"])["id"],
449
+ description: row["description"],
450
+ memberCount: row["member_count"],
451
+ childcare: row["childcare"],
452
+ listed: row["listed"],
453
+ full: row["full"],
454
+ meetDay: asRecord(row["meet_day"])["name"],
455
+ meetTime: asRecord(row["meet_time"])["name"],
456
+ area: asRecord(row["area"])["name"],
457
+ address: address(row["address"]),
458
+ });
459
+ }
460
+ function projectMembership(value) {
461
+ const row = asRecord(value);
462
+ const person = asRecord(row["individual"]);
463
+ const group = asRecord(row["group"]);
464
+ return compact({
465
+ groupId: row["group_id"],
466
+ individualId: row["individual_id"],
467
+ status: row["status"],
468
+ dateAdded: row["date_added"],
469
+ name: person["name"],
470
+ email: person["email"] || undefined,
471
+ phones: phones(person["phone"]),
472
+ groupName: group["name"],
473
+ groupType: asRecord(group["group_type"])["name"],
474
+ groupInactive: group["inactive"],
475
+ });
476
+ }
477
+ function projectOccurrence(value) {
478
+ const row = asRecord(value);
479
+ const event = asRecord(row["event"]);
480
+ const group = asRecord(event["group"]);
481
+ return compact({
482
+ eventId: row["event_id"] ?? event["id"],
483
+ occurrence: row["occurrence"],
484
+ start: row["start"],
485
+ end: row["end"],
486
+ name: event["name"],
487
+ campusId: event["campus_id"],
488
+ recurs: event["recurs"],
489
+ groupId: group["id"] === undefined ? undefined : Number(group["id"]),
490
+ groupName: group["name"],
491
+ });
492
+ }
493
+ function projectEvent(value) {
494
+ const row = asRecord(value);
495
+ const attendance = asRecord(row["attendance"]);
496
+ return compact({
497
+ id: row["id"],
498
+ name: row["name"],
499
+ description: row["description"],
500
+ campusId: row["campus_id"],
501
+ start: row["start"],
502
+ end: row["end"],
503
+ recurs: row["recurs"],
504
+ recurEndDate: row["recur_end_date"],
505
+ approvalStatus: row["approval_status"],
506
+ address: address(row["address"]),
507
+ guestCounts: present(compact({ ...asRecord(row["guest_counts"]) })),
508
+ group: ref(row["group"]),
509
+ attendance: present(compact({
510
+ status: attendance["status"],
511
+ totalAttendance: attendance["total_attendance"],
512
+ visitors: attendance["visitors"],
513
+ })),
514
+ });
515
+ }
516
+ function projectAttendance(value) {
517
+ const row = asRecord(value);
518
+ return compact({
519
+ eventId: row["event_id"],
520
+ occurrence: row["occurrence"],
521
+ status: row["status"],
522
+ totalAttendance: row["total_attendance"],
523
+ visitors: row["visitors"],
524
+ topic: row["topic"],
525
+ });
526
+ }
527
+ function projectAttendee(value) {
528
+ const row = asRecord(value);
529
+ const person = asRecord(row["individual"]);
530
+ return compact({
531
+ individualId: row["individual_id"] ?? person["id"],
532
+ name: person["name"],
533
+ familyId: person["family_id"],
534
+ occurrenceDate: row["occurrence_date"],
535
+ });
536
+ }
537
+ function projectForm(value) {
538
+ const row = asRecord(value);
539
+ return compact({
540
+ id: row["id"],
541
+ title: row["title"],
542
+ status: row["status"],
543
+ campusId: row["campus_id"],
544
+ start: row["start"],
545
+ end: row["end"],
546
+ public: row["public"],
547
+ url: row["url"],
548
+ });
549
+ }
550
+ function projectResponse(value) {
551
+ const row = asRecord(value);
552
+ return compact({
553
+ id: row["id"],
554
+ formId: row["form_id"],
555
+ individualId: row["individual_id"],
556
+ created: row["created"],
557
+ paymentStatus: row["payment_status"],
558
+ answers: asArray(row["answers"]).map((answer) => {
559
+ const item = asRecord(answer);
560
+ return compact({ questionId: item["question_id"], answer: item["answer"] });
561
+ }),
562
+ });
563
+ }
564
+ function projectProcess(value) {
565
+ const row = asRecord(value);
566
+ return compact({
567
+ id: row["id"],
568
+ name: row["name"],
569
+ campusId: row["campus_id"],
570
+ ownerId: row["owner_id"],
571
+ hidden: row["hidden"],
572
+ archived: row["archived"],
573
+ managers: Array.isArray(row["process_managers"]) ? row["process_managers"].map(ref).filter(Boolean) : undefined,
574
+ });
575
+ }
576
+ function projectQueue(value) {
577
+ const row = asRecord(value);
578
+ return compact({
579
+ id: row["id"],
580
+ name: row["name"],
581
+ processId: row["process_id"],
582
+ processName: row["process_name"] ?? asRecord(row["process"])["name"],
583
+ description: row["description"] || undefined,
584
+ assignedCount: row["assigned_count"],
585
+ unassignedCount: row["unassigned_count"],
586
+ });
587
+ }
588
+ function projectQueueIndividual(value) {
589
+ const row = asRecord(value);
590
+ const queue = asRecord(row["queue"]);
591
+ const process = asRecord(row["process"]);
592
+ return compact({
593
+ queueIndividualId: row["queue_individual_id"],
594
+ queueId: row["queue_id"],
595
+ queueName: queue["name"],
596
+ processId: process["id"] ?? queue["process_id"],
597
+ processName: process["name"] ?? queue["process_name"],
598
+ individualId: row["individual_id"],
599
+ name: asRecord(row["individual"])["name"],
600
+ status: row["status"],
601
+ due: row["due"],
602
+ created: row["created"],
603
+ completed: row["completed"],
604
+ });
605
+ }
606
+ function projectCategory(value) {
607
+ const row = asRecord(value);
608
+ return compact({
609
+ id: row["id"],
610
+ name: row["name"],
611
+ campus: ref(row["campus"]),
612
+ organizer: ref(row["organizer"]),
613
+ archived: row["archived"],
614
+ teams: Array.isArray(row["teams"]) ? row["teams"].map(ref).filter(Boolean) : undefined,
615
+ });
616
+ }
617
+ function projectSchedule(value) {
618
+ const row = asRecord(value);
619
+ return compact({
620
+ id: row["id"],
621
+ categoryId: row["category_id"],
622
+ name: row["name"],
623
+ start: row["start"],
624
+ end: row["end"],
625
+ needed: row["needed"],
626
+ events: Array.isArray(row["events"])
627
+ ? row["events"].map((event) => {
628
+ const item = asRecord(event);
629
+ return compact({ id: item["id"], name: item["name"], start: item["start"], end: item["end"] });
630
+ })
631
+ : undefined,
632
+ metrics: present(compact({ ...asRecord(row["metrics"]) })),
633
+ });
634
+ }
635
+ function projectAssignment(value) {
636
+ const row = asRecord(value);
637
+ const volunteer = asRecord(row["volunteer"]);
638
+ const person = asRecord(volunteer["individual"]);
639
+ return compact({
640
+ id: row["id"],
641
+ categoryId: row["category_id"],
642
+ scheduleId: row["schedule_id"],
643
+ eventId: row["event_id"],
644
+ eventPositionId: row["event_position_id"],
645
+ status: row["status"],
646
+ statusReason: row["status_reason"] || undefined,
647
+ dateNotified: row["date_notified"],
648
+ individualId: person["id"],
649
+ name: person["name"],
650
+ });
651
+ }
652
+ function projectNote(value) {
653
+ const row = asRecord(value);
654
+ return compact({
655
+ id: row["id"],
656
+ individualId: row["individual_id"],
657
+ note: row["note"],
658
+ date: row["date"],
659
+ sharingLevel: row["sharing_level"],
660
+ context: row["context"],
661
+ contextId: row["context_id"],
662
+ creator: ref(row["creator"]),
663
+ });
664
+ }
665
+ function projectPledge(value) {
666
+ const row = asRecord(value);
667
+ const recurrence = asRecord(row["recurrence"]);
668
+ return compact({
669
+ id: row["id"],
670
+ campusId: row["campus_id"],
671
+ fund: asRecord(row["coa"])["name"],
672
+ pledged: row["pledged"],
673
+ paid: row["paid"],
674
+ expected: row["expected"],
675
+ total: row["total"],
676
+ frequency: recurrence["frequency"],
677
+ start: row["start"],
678
+ end: recurrence["end"],
679
+ });
680
+ }
681
+ // ---------------------------------------------------------------------------
682
+ // Schemas
683
+ // ---------------------------------------------------------------------------
684
+ const INT = { type: "integer" };
685
+ const STR = { type: "string" };
686
+ const BOOL = { type: "boolean" };
687
+ const DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$";
688
+ function obj(properties) {
689
+ return { type: "object", properties };
690
+ }
691
+ function idProperty(description) {
692
+ return { type: "integer", minimum: 1, description };
693
+ }
694
+ const RAW = {
695
+ type: "boolean",
696
+ description: "Return CCB's untouched rows, including the fields the projection drops.",
697
+ };
698
+ const PAGE = {
699
+ type: "integer",
700
+ minimum: 1,
701
+ description: "1-based page number; pass page.nextPage from the previous result.",
702
+ };
703
+ function perPageProperty(defaultPerPage) {
704
+ return {
705
+ type: "integer",
706
+ enum: [...PER_PAGE_VALUES],
707
+ description: `Rows per page. CCB accepts only these values; defaults to ${defaultPerPage}.`,
708
+ };
709
+ }
710
+ const PAGE_SCHEMA = obj({
711
+ hasMore: BOOL,
712
+ nextPage: { type: ["integer", "null"] },
713
+ total: { type: ["integer", "null"] },
714
+ });
715
+ function listOutput(key, item) {
716
+ return {
717
+ type: "object",
718
+ properties: { [key]: { type: "array", items: item }, page: PAGE_SCHEMA },
719
+ required: [key, "page"],
720
+ };
721
+ }
722
+ function rowsOutput(key, item) {
723
+ return {
724
+ type: "object",
725
+ properties: { [key]: { type: "array", items: item } },
726
+ required: [key],
727
+ };
728
+ }
729
+ function input(properties, required = []) {
730
+ return { type: "object", properties, required, additionalProperties: false };
731
+ }
732
+ const PHONES = obj({ mobile: STR, home: STR, work: STR });
733
+ const PERSON_SCHEMA = obj({
734
+ id: INT,
735
+ name: STR,
736
+ firstName: STR,
737
+ lastName: STR,
738
+ email: STR,
739
+ phones: PHONES,
740
+ birthday: STR,
741
+ familyId: INT,
742
+ familyPosition: STR,
743
+ campusId: INT,
744
+ membershipTypeId: INT,
745
+ active: BOOL,
746
+ deceased: STR,
747
+ });
748
+ const ADDRESS = obj({ street: STR, city: STR, state: STR, zip: STR, country: STR });
749
+ const INDIVIDUAL_SCHEMA = obj({
750
+ ...PERSON_SCHEMA.properties,
751
+ gender: STR,
752
+ maritalStatus: STR,
753
+ homeAddress: ADDRESS,
754
+ mailingAddress: ADDRESS,
755
+ campusName: STR,
756
+ membershipTypeName: STR,
757
+ membershipDate: STR,
758
+ baptized: BOOL,
759
+ lastAttendedDate: STR,
760
+ listed: BOOL,
761
+ customFields: { type: "object" },
762
+ });
763
+ const FAMILY_SCHEMA = obj({
764
+ id: INT,
765
+ lastName: STR,
766
+ address: ADDRESS,
767
+ members: { type: "array", items: PERSON_SCHEMA },
768
+ });
769
+ const REF = obj({ id: INT, name: STR });
770
+ const GROUP_SCHEMA = obj({
771
+ id: INT,
772
+ name: STR,
773
+ campusId: INT,
774
+ inactive: BOOL,
775
+ groupType: STR,
776
+ interactionType: STR,
777
+ membershipType: STR,
778
+ mainLeader: REF,
779
+ departmentId: INT,
780
+ });
781
+ const GROUP_DETAIL_SCHEMA = obj({
782
+ ...GROUP_SCHEMA.properties,
783
+ description: STR,
784
+ memberCount: INT,
785
+ childcare: BOOL,
786
+ listed: BOOL,
787
+ full: BOOL,
788
+ meetDay: STR,
789
+ meetTime: STR,
790
+ area: STR,
791
+ address: ADDRESS,
792
+ });
793
+ const MEMBERSHIP_SCHEMA = obj({
794
+ groupId: INT,
795
+ individualId: INT,
796
+ status: STR,
797
+ dateAdded: STR,
798
+ name: STR,
799
+ email: STR,
800
+ phones: PHONES,
801
+ groupName: STR,
802
+ groupType: STR,
803
+ groupInactive: BOOL,
804
+ });
805
+ const OCCURRENCE_SCHEMA = obj({
806
+ eventId: INT,
807
+ occurrence: STR,
808
+ start: STR,
809
+ end: STR,
810
+ name: STR,
811
+ campusId: INT,
812
+ recurs: BOOL,
813
+ groupId: INT,
814
+ groupName: STR,
815
+ });
816
+ const EVENT_SCHEMA = obj({
817
+ id: INT,
818
+ name: STR,
819
+ description: STR,
820
+ campusId: INT,
821
+ start: STR,
822
+ end: STR,
823
+ recurs: BOOL,
824
+ recurEndDate: STR,
825
+ approvalStatus: STR,
826
+ address: ADDRESS,
827
+ guestCounts: { type: "object" },
828
+ group: REF,
829
+ attendance: obj({ status: STR, totalAttendance: INT, visitors: INT }),
830
+ });
831
+ const ATTENDANCE_SCHEMA = obj({
832
+ eventId: INT,
833
+ occurrence: STR,
834
+ status: STR,
835
+ totalAttendance: INT,
836
+ visitors: INT,
837
+ topic: STR,
838
+ });
839
+ const ATTENDEE_SCHEMA = obj({ individualId: INT, name: STR, familyId: INT, occurrenceDate: STR });
840
+ const FORM_SCHEMA = obj({
841
+ id: INT,
842
+ title: STR,
843
+ status: STR,
844
+ campusId: INT,
845
+ start: STR,
846
+ end: STR,
847
+ public: BOOL,
848
+ url: STR,
849
+ });
850
+ const RESPONSE_SCHEMA = obj({
851
+ id: INT,
852
+ formId: INT,
853
+ individualId: INT,
854
+ created: STR,
855
+ paymentStatus: STR,
856
+ answers: { type: "array", items: obj({ questionId: INT, answer: STR }) },
857
+ });
858
+ const PROCESS_SCHEMA = obj({
859
+ id: INT,
860
+ name: STR,
861
+ campusId: INT,
862
+ ownerId: INT,
863
+ hidden: BOOL,
864
+ archived: BOOL,
865
+ managers: { type: "array", items: REF },
866
+ });
867
+ const QUEUE_SCHEMA = obj({
868
+ id: INT,
869
+ name: STR,
870
+ processId: INT,
871
+ processName: STR,
872
+ description: STR,
873
+ assignedCount: INT,
874
+ unassignedCount: INT,
875
+ });
876
+ const QUEUE_INDIVIDUAL_SCHEMA = obj({
877
+ queueIndividualId: INT,
878
+ queueId: INT,
879
+ queueName: STR,
880
+ processId: INT,
881
+ processName: STR,
882
+ individualId: INT,
883
+ name: STR,
884
+ status: STR,
885
+ due: STR,
886
+ created: STR,
887
+ completed: STR,
888
+ });
889
+ const CATEGORY_SCHEMA = obj({
890
+ id: INT,
891
+ name: STR,
892
+ campus: REF,
893
+ organizer: REF,
894
+ archived: BOOL,
895
+ teams: { type: "array", items: REF },
896
+ });
897
+ const SCHEDULE_SCHEMA = obj({
898
+ id: INT,
899
+ categoryId: INT,
900
+ name: STR,
901
+ start: STR,
902
+ end: STR,
903
+ needed: INT,
904
+ events: { type: "array", items: obj({ id: INT, name: STR, start: STR, end: STR }) },
905
+ metrics: { type: "object" },
906
+ });
907
+ const ASSIGNMENT_SCHEMA = obj({
908
+ id: INT,
909
+ categoryId: INT,
910
+ scheduleId: INT,
911
+ eventId: INT,
912
+ eventPositionId: INT,
913
+ status: STR,
914
+ statusReason: STR,
915
+ dateNotified: STR,
916
+ individualId: INT,
917
+ name: STR,
918
+ });
919
+ const NOTE_SCHEMA = obj({
920
+ id: INT,
921
+ individualId: INT,
922
+ note: STR,
923
+ date: STR,
924
+ sharingLevel: STR,
925
+ context: STR,
926
+ contextId: INT,
927
+ creator: REF,
928
+ });
929
+ const PLEDGE_SCHEMA = obj({
930
+ id: INT,
931
+ campusId: INT,
932
+ fund: STR,
933
+ pledged: { type: "number" },
934
+ paid: { type: "number" },
935
+ expected: { type: "number" },
936
+ total: { type: "number" },
937
+ frequency: STR,
938
+ start: STR,
939
+ end: STR,
940
+ });
941
+ const ME_SCHEMA = obj({
942
+ id: INT,
943
+ name: STR,
944
+ email: STR,
945
+ username: STR,
946
+ familyId: INT,
947
+ campusId: INT,
948
+ userTypes: { type: "array", items: STR },
949
+ });
950
+ /** Domains with a `POST /search/{domain}/results` endpoint in the v2 spec. */
951
+ const SEARCH_DOMAINS = [
952
+ "assignments",
953
+ "categories",
954
+ "department_detail",
955
+ "groups",
956
+ "individual_event_attendance",
957
+ "individual_groups",
958
+ "individuals",
959
+ "organizers",
960
+ "positions",
961
+ "schedules",
962
+ "schedules_totals",
963
+ "scheduling_files",
964
+ "scheduling_individuals",
965
+ "songs",
966
+ "volunteers",
967
+ ];
968
+ const HATCH_PATH = {
969
+ type: "string",
970
+ minLength: 2,
971
+ maxLength: 512,
972
+ pattern: "^/[^?#]*$",
973
+ description: "Path below the API origin beginning with '/', such as /campuses. No query string; no /oauth paths.",
974
+ };
975
+ const QUERY = {
976
+ type: "object",
977
+ description: "Query parameters by name, one value each, such as { per_page: 50 }.",
978
+ additionalProperties: { type: ["string", "number", "boolean"] },
979
+ };
980
+ const RESULT_OUTPUT = {
981
+ type: "object",
982
+ properties: {
983
+ result: { description: "CCB's untouched response body, or null when empty." },
984
+ page: PAGE_SCHEMA,
985
+ },
986
+ required: ["result"],
987
+ };
988
+ /**
989
+ * The hatch path, refused locally where it could only fail or should not be
990
+ * sent: the token endpoint shares the API origin, and sending the grant's
991
+ * bearer token to `/oauth/*` is never what a call meant.
992
+ */
993
+ function hatchPath(value) {
994
+ const path = String(value);
995
+ // Judged after the same normalization the transport applies, and after
996
+ // percent-decoding, so `/groups/../oauth/token` and `/%6Fauth/token` are
997
+ // the token endpoint they resolve to.
998
+ let resolved = path;
999
+ try {
1000
+ resolved = new URL(`https://ccb.invalid${path.startsWith("/") ? path : `/${path}`}`).pathname;
1001
+ resolved = decodeURIComponent(resolved);
1002
+ }
1003
+ catch {
1004
+ // An unparseable path is refused by the transport's own confinement.
1005
+ }
1006
+ if (/^\/+oauth(\/|$)/i.test(path) || /^\/+oauth(\/|$)/i.test(resolved)) {
1007
+ throw new ConnectorCallError("invalid_args", "CCB /oauth paths belong to the connector's grant, not to API calls.");
1008
+ }
1009
+ return path;
1010
+ }
1011
+ /** Scalar query values only; the schema admits nothing else. */
1012
+ function hatchQuery(value) {
1013
+ const query = {};
1014
+ for (const [name, item] of Object.entries(asRecord(value))) {
1015
+ if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") {
1016
+ query[name] = item;
1017
+ }
1018
+ }
1019
+ return query;
1020
+ }
1021
+ function pageQuery(args, defaultPerPage) {
1022
+ return {
1023
+ page: typeof args["page"] === "number" ? args["page"] : 1,
1024
+ per_page: typeof args["perPage"] === "number" ? args["perPage"] : defaultPerPage,
1025
+ };
1026
+ }
1027
+ function compactDate(value) {
1028
+ return typeof value === "string" ? value.replace(/-/g, "") : undefined;
1029
+ }
1030
+ const OCCURRENCE = {
1031
+ type: "string",
1032
+ pattern: "^\\d{4}-?\\d{2}-?\\d{2}$",
1033
+ description: "Occurrence date, YYYYMMDD or YYYY-MM-DD, from list_events.",
1034
+ };
1035
+ function dateProperty(description) {
1036
+ return { type: "string", pattern: DATE_PATTERN, description };
1037
+ }
1038
+ const DAY_MS = 86_400_000;
1039
+ // ---------------------------------------------------------------------------
1040
+ // Tools
1041
+ // ---------------------------------------------------------------------------
1042
+ function tools(send, defaultPerPage, writable) {
1043
+ const readOnly = { readOnlyHint: true };
1044
+ const perPage = perPageProperty(defaultPerPage);
1045
+ /** A page-numbered list: one GET, projected rows, and the page signal. */
1046
+ const list = (path, args, ctx, key, project, query = {}) => callCcb(send, { method: "GET", path, query: { ...query, ...pageQuery(args, defaultPerPage) } }, ctx).then(({ body, headers }) => {
1047
+ const rows = asArray(body);
1048
+ const paging = pageQuery(args, defaultPerPage);
1049
+ return {
1050
+ [key]: args["raw"] === true ? rows : rows.map(project),
1051
+ page: pageInfo(headers, paging.page, paging.per_page, rows.length),
1052
+ };
1053
+ });
1054
+ /** An unpaged GET whose body is one record. */
1055
+ const one = async (path, args, ctx, project, query = {}) => {
1056
+ const { body } = await callCcb(send, { method: "GET", path, query }, ctx);
1057
+ return args["raw"] === true ? body : project(body);
1058
+ };
1059
+ const id = (value) => encodeURIComponent(String(value));
1060
+ const named = [
1061
+ {
1062
+ name: "get_me",
1063
+ description: "Get the CCB individual the access token belongs to. Under Identity Auth that is the signed-in person whose permissions every call carries.",
1064
+ annotations: readOnly,
1065
+ inputSchema: input({}),
1066
+ outputSchema: ME_SCHEMA,
1067
+ handler: async (_args, ctx) => {
1068
+ const { body } = await callCcb(send, { method: "GET", path: "/me" }, ctx);
1069
+ const row = asRecord(body);
1070
+ return compact({
1071
+ id: row["id"],
1072
+ name: row["name"],
1073
+ email: row["email"] || undefined,
1074
+ username: row["username"],
1075
+ familyId: row["family_id"],
1076
+ campusId: row["campus_id"],
1077
+ userTypes: Array.isArray(row["user_types"]) ? row["user_types"] : undefined,
1078
+ });
1079
+ },
1080
+ },
1081
+ {
1082
+ name: "list_individuals",
1083
+ description: "Find CCB individuals by partial name, phone, or email. Returns lean profiles with family and campus ids; not the advanced search.",
1084
+ annotations: readOnly,
1085
+ inputSchema: input({
1086
+ query: {
1087
+ type: "string",
1088
+ minLength: 1,
1089
+ maxLength: 200,
1090
+ description: "Partial name, phone number, or email. Omit to page everyone.",
1091
+ },
1092
+ includeInactive: { type: "boolean", description: "Include inactive individuals." },
1093
+ includeDeceased: { type: "boolean", description: "Include deceased individuals." },
1094
+ campusScope: {
1095
+ type: "string",
1096
+ enum: ["all", "current", "other"],
1097
+ description: "Campuses to search relative to the connection's own.",
1098
+ },
1099
+ page: PAGE,
1100
+ perPage,
1101
+ raw: RAW,
1102
+ }),
1103
+ outputSchema: listOutput("individuals", PERSON_SCHEMA),
1104
+ handler: (args, ctx) => list("/individuals", args, ctx, "individuals", projectPerson, {
1105
+ name: args["query"],
1106
+ include_inactive: args["includeInactive"],
1107
+ include_deceased: args["includeDeceased"],
1108
+ campus_scope: args["campusScope"],
1109
+ }),
1110
+ },
1111
+ {
1112
+ name: "get_individual",
1113
+ description: "Get one CCB individual's profile: contact, addresses, membership, and custom fields. CCB allows one call a second; prefer list_individuals for many people.",
1114
+ annotations: readOnly,
1115
+ inputSchema: input({ individualId: idProperty("Individual id from list_individuals."), raw: RAW }, [
1116
+ "individualId",
1117
+ ]),
1118
+ outputSchema: INDIVIDUAL_SCHEMA,
1119
+ handler: (args, ctx) => one(`/individuals/${id(args["individualId"])}`, args, ctx, projectIndividual),
1120
+ },
1121
+ {
1122
+ name: "list_families",
1123
+ description: "Find CCB families by partial last name, email, or mobile phone. Returns family ids and addresses; get_family lists the members.",
1124
+ annotations: readOnly,
1125
+ inputSchema: input({
1126
+ lastName: { type: "string", minLength: 1, maxLength: 200, description: "Partial last name." },
1127
+ email: { type: "string", minLength: 1, maxLength: 200, description: "Partial email." },
1128
+ phone: { type: "string", minLength: 1, maxLength: 50, description: "Partial mobile phone." },
1129
+ page: PAGE,
1130
+ perPage,
1131
+ raw: RAW,
1132
+ }),
1133
+ outputSchema: listOutput("families", FAMILY_SCHEMA),
1134
+ handler: (args, ctx) => list("/families", args, ctx, "families", projectFamily, {
1135
+ last_name: args["lastName"],
1136
+ email: args["email"],
1137
+ phone: args["phone"],
1138
+ }),
1139
+ },
1140
+ {
1141
+ name: "get_family",
1142
+ description: "Get one CCB family with every member's lean profile and family position.",
1143
+ annotations: readOnly,
1144
+ inputSchema: input({
1145
+ familyId: idProperty("Family id from an individual's familyId or list_families."),
1146
+ includeInactive: { type: "boolean", description: "Include inactive members." },
1147
+ includeDeceased: { type: "boolean", description: "Include deceased members." },
1148
+ raw: RAW,
1149
+ }, ["familyId"]),
1150
+ outputSchema: FAMILY_SCHEMA,
1151
+ handler: (args, ctx) => one(`/families/${id(args["familyId"])}`, args, ctx, projectFamily, {
1152
+ include_inactive: args["includeInactive"],
1153
+ include_deceased: args["includeDeceased"],
1154
+ }),
1155
+ },
1156
+ {
1157
+ name: "list_groups",
1158
+ description: "List or search CCB groups by partial name, optionally for one campus. Returns group type, leader, and membership mode.",
1159
+ annotations: readOnly,
1160
+ inputSchema: input({
1161
+ name: { type: "string", minLength: 1, maxLength: 200, description: "Partial group name." },
1162
+ campusId: idProperty("Only groups at this campus."),
1163
+ page: PAGE,
1164
+ perPage,
1165
+ raw: RAW,
1166
+ }),
1167
+ outputSchema: listOutput("groups", GROUP_SCHEMA),
1168
+ handler: (args, ctx) => list("/groups", args, ctx, "groups", projectGroup, {
1169
+ name: args["name"],
1170
+ campus_ids: args["campusId"],
1171
+ }),
1172
+ },
1173
+ {
1174
+ name: "get_group",
1175
+ description: "Get one CCB group: description, member count, meeting day, time, area, and address.",
1176
+ annotations: readOnly,
1177
+ inputSchema: input({ groupId: idProperty("Group id from list_groups."), raw: RAW }, ["groupId"]),
1178
+ outputSchema: GROUP_DETAIL_SCHEMA,
1179
+ handler: (args, ctx) => one(`/groups/${id(args["groupId"])}`, args, ctx, projectGroup),
1180
+ },
1181
+ {
1182
+ name: "list_group_members",
1183
+ description: "List one CCB group's participants with role and date added. Filter by leader or member status, or partial name.",
1184
+ annotations: readOnly,
1185
+ inputSchema: input({
1186
+ groupId: idProperty("Group id from list_groups."),
1187
+ status: { type: "string", enum: ["all", "leader", "member"], description: "Participant role filter." },
1188
+ name: { type: "string", minLength: 1, maxLength: 200, description: "Partial participant name." },
1189
+ page: PAGE,
1190
+ perPage,
1191
+ raw: RAW,
1192
+ }, ["groupId"]),
1193
+ outputSchema: listOutput("members", MEMBERSHIP_SCHEMA),
1194
+ handler: (args, ctx) => list(`/groups/${id(args["groupId"])}/members`, args, ctx, "members", projectMembership, {
1195
+ status: args["status"],
1196
+ name: args["name"],
1197
+ }),
1198
+ },
1199
+ {
1200
+ name: "list_individual_groups",
1201
+ description: "List the CCB groups one individual belongs to, with their role. Defaults to member and leader roles.",
1202
+ annotations: readOnly,
1203
+ inputSchema: input({
1204
+ individualId: idProperty("Individual id."),
1205
+ includeInactive: { type: "boolean", description: "List inactive groups instead of active ones." },
1206
+ page: PAGE,
1207
+ perPage,
1208
+ raw: RAW,
1209
+ }, ["individualId"]),
1210
+ outputSchema: listOutput("groups", MEMBERSHIP_SCHEMA),
1211
+ handler: (args, ctx) => list(`/individuals/${id(args["individualId"])}/groups`, args, ctx, "groups", projectMembership, {
1212
+ inactive: args["includeInactive"],
1213
+ }),
1214
+ },
1215
+ {
1216
+ name: "list_events",
1217
+ description: "List CCB event occurrences by name and time range. Each row's eventId and occurrence feed the attendance tools.",
1218
+ annotations: readOnly,
1219
+ inputSchema: input({
1220
+ name: { type: "string", minLength: 1, maxLength: 200, description: "Partial event name." },
1221
+ range: {
1222
+ type: "string",
1223
+ enum: ["ALL", "PAST", "CURRENT", "FUTURE"],
1224
+ description: "Time range relative to now.",
1225
+ },
1226
+ page: PAGE,
1227
+ perPage,
1228
+ raw: RAW,
1229
+ }),
1230
+ outputSchema: listOutput("occurrences", OCCURRENCE_SCHEMA),
1231
+ handler: (args, ctx) => list("/events", args, ctx, "occurrences", projectOccurrence, {
1232
+ name: args["name"],
1233
+ range_type: args["range"],
1234
+ }),
1235
+ },
1236
+ {
1237
+ name: "get_event",
1238
+ description: "Get one CCB event's details, optionally for one occurrence with its attendance totals.",
1239
+ annotations: readOnly,
1240
+ inputSchema: input({
1241
+ eventId: idProperty("Event id from list_events."),
1242
+ occurrence: { ...OCCURRENCE, description: "Occurrence date, YYYYMMDD or YYYY-MM-DD. Omit for the series." },
1243
+ withAttendance: { type: "boolean", description: "Add attendance totals for the occurrence." },
1244
+ raw: RAW,
1245
+ }, ["eventId"]),
1246
+ outputSchema: EVENT_SCHEMA,
1247
+ handler: (args, ctx) => one(`/events/${id(args["eventId"])}`, args, ctx, projectEvent, {
1248
+ occurrence: compactDate(args["occurrence"]),
1249
+ attendance_info: args["withAttendance"],
1250
+ }),
1251
+ },
1252
+ {
1253
+ name: "get_event_attendance",
1254
+ description: "Get one CCB event occurrence's attendance summary: met or not, totals, visitors, topic. Drops leader notes and prayer requests unless raw.",
1255
+ annotations: readOnly,
1256
+ inputSchema: input({ eventId: idProperty("Event id from list_events."), occurrence: OCCURRENCE, raw: RAW }, [
1257
+ "eventId",
1258
+ "occurrence",
1259
+ ]),
1260
+ outputSchema: ATTENDANCE_SCHEMA,
1261
+ handler: (args, ctx) => one(`/events/${id(args["eventId"])}/attendance/${compactDate(args["occurrence"])}`, args, ctx, projectAttendance),
1262
+ },
1263
+ {
1264
+ name: "list_event_attendees",
1265
+ description: "List who attended one CCB event occurrence. Returns individual ids and names.",
1266
+ annotations: readOnly,
1267
+ inputSchema: input({ eventId: idProperty("Event id from list_events."), occurrence: OCCURRENCE, page: PAGE, perPage, raw: RAW }, ["eventId", "occurrence"]),
1268
+ outputSchema: listOutput("attendees", ATTENDEE_SCHEMA),
1269
+ handler: (args, ctx) => list(`/events/${id(args["eventId"])}/attendance/${compactDate(args["occurrence"])}/attendees`, args, ctx, "attendees", projectAttendee),
1270
+ },
1271
+ {
1272
+ name: "list_forms",
1273
+ description: "List or search CCB forms by partial title, status, or campus. Returns form ids for list_form_responses.",
1274
+ annotations: readOnly,
1275
+ inputSchema: input({
1276
+ title: { type: "string", minLength: 1, maxLength: 200, description: "Partial form title." },
1277
+ status: {
1278
+ type: "string",
1279
+ enum: ["ACTIVE", "SCHEDULED", "UNPUBLISHED", "EXPIRED", "ARCHIVED"],
1280
+ description: "Form status.",
1281
+ },
1282
+ campusId: idProperty("Only forms at this campus."),
1283
+ page: PAGE,
1284
+ perPage,
1285
+ raw: RAW,
1286
+ }),
1287
+ outputSchema: listOutput("forms", FORM_SCHEMA),
1288
+ handler: (args, ctx) => list("/forms", args, ctx, "forms", projectForm, {
1289
+ title: args["title"],
1290
+ status: args["status"],
1291
+ campus_id: args["campusId"],
1292
+ }),
1293
+ },
1294
+ {
1295
+ name: "list_form_responses",
1296
+ description: "List one CCB form's responses with answers by question id and payment status. Drops payment details unless raw.",
1297
+ annotations: readOnly,
1298
+ inputSchema: input({ formId: idProperty("Form id from list_forms."), page: PAGE, perPage, raw: RAW }, ["formId"]),
1299
+ outputSchema: listOutput("responses", RESPONSE_SCHEMA),
1300
+ handler: (args, ctx) => list(`/forms/${id(args["formId"])}/responses`, args, ctx, "responses", projectResponse),
1301
+ },
1302
+ {
1303
+ name: "list_processes",
1304
+ description: "List CCB processes (assimilation and follow-up pipelines) by name or status. Returns process ids for list_queues.",
1305
+ annotations: readOnly,
1306
+ inputSchema: input({
1307
+ name: { type: "string", minLength: 1, maxLength: 200, description: "Process name search." },
1308
+ status: { type: "string", enum: ["HIDDEN", "ACTIVE", "ARCHIVED"], description: "Process status." },
1309
+ managedByMe: { type: "boolean", description: "Only processes the connection's individual manages." },
1310
+ page: PAGE,
1311
+ perPage,
1312
+ raw: RAW,
1313
+ }),
1314
+ outputSchema: listOutput("processes", PROCESS_SCHEMA),
1315
+ handler: (args, ctx) => list("/processes", args, ctx, "processes", projectProcess, {
1316
+ name: args["name"],
1317
+ statuses: args["status"],
1318
+ managed_by_me: args["managedByMe"],
1319
+ }),
1320
+ },
1321
+ {
1322
+ name: "list_queues",
1323
+ description: "List CCB process queues, optionally for one process, with assigned and unassigned counts.",
1324
+ annotations: readOnly,
1325
+ inputSchema: input({
1326
+ processId: idProperty("Only queues in this process."),
1327
+ search: { type: "string", minLength: 1, maxLength: 200, description: "Queue name search." },
1328
+ page: PAGE,
1329
+ perPage,
1330
+ raw: RAW,
1331
+ }),
1332
+ outputSchema: listOutput("queues", QUEUE_SCHEMA),
1333
+ handler: (args, ctx) => list("/queues", args, ctx, "queues", projectQueue, {
1334
+ process_ids: args["processId"],
1335
+ search_term: args["search"],
1336
+ }),
1337
+ },
1338
+ {
1339
+ name: "list_queue_individuals",
1340
+ description: "List people in CCB process queues the connection can see, by status or assignment. CCB offers no per-queue filter here; filter on queueId.",
1341
+ annotations: readOnly,
1342
+ inputSchema: input({
1343
+ status: {
1344
+ type: "string",
1345
+ enum: ["NOT_STARTED", "WAITING", "IN_PROCESS", "DONE"],
1346
+ description: "Queue status.",
1347
+ },
1348
+ assignment: { type: "string", enum: ["ASSIGNED_TO_ME", "UNASSIGNED"], description: "Omit for both." },
1349
+ page: PAGE,
1350
+ perPage,
1351
+ raw: RAW,
1352
+ }),
1353
+ outputSchema: listOutput("queueIndividuals", QUEUE_INDIVIDUAL_SCHEMA),
1354
+ handler: (args, ctx) => list("/queues_individuals", args, ctx, "queueIndividuals", projectQueueIndividual, {
1355
+ statuses: args["status"],
1356
+ assignment: args["assignment"],
1357
+ }),
1358
+ },
1359
+ {
1360
+ name: "list_scheduling_categories",
1361
+ description: "List CCB scheduling categories (serving ministries) with teams and organizer. Category ids feed list_schedules.",
1362
+ annotations: readOnly,
1363
+ inputSchema: input({
1364
+ search: { type: "string", minLength: 1, maxLength: 200, description: "Category name search." },
1365
+ status: {
1366
+ type: "string",
1367
+ enum: ["ACTIVE", "ARCHIVED", "ACTIVE_AND_ARCHIVED"],
1368
+ description: "Defaults to CCB's own (active).",
1369
+ },
1370
+ raw: RAW,
1371
+ }),
1372
+ outputSchema: rowsOutput("categories", CATEGORY_SCHEMA),
1373
+ handler: async (args, ctx) => {
1374
+ const { body } = await callCcb(send, {
1375
+ method: "GET",
1376
+ path: "/scheduling/categories",
1377
+ query: { search_term: args["search"], status: args["status"] },
1378
+ }, ctx);
1379
+ const rows = Array.isArray(body) ? body : body && typeof body === "object" ? [body] : [];
1380
+ return { categories: args["raw"] === true ? rows : rows.map(projectCategory) };
1381
+ },
1382
+ },
1383
+ {
1384
+ name: "list_schedules",
1385
+ description: "List one CCB scheduling category's schedules with events and assignment metrics. CCB allows one call per two seconds here.",
1386
+ annotations: readOnly,
1387
+ inputSchema: input({
1388
+ categoryId: idProperty("Category id from list_scheduling_categories."),
1389
+ after: dateProperty("Only schedules ending after this date, YYYY-MM-DD."),
1390
+ before: dateProperty("Only schedules ending before this date, YYYY-MM-DD."),
1391
+ page: PAGE,
1392
+ perPage,
1393
+ raw: RAW,
1394
+ }, ["categoryId"]),
1395
+ outputSchema: listOutput("schedules", SCHEDULE_SCHEMA),
1396
+ handler: (args, ctx) => list(`/scheduling/categories/${id(args["categoryId"])}/schedules`, args, ctx, "schedules", projectSchedule, {
1397
+ after: args["after"],
1398
+ before: args["before"],
1399
+ withMetrics: true,
1400
+ }),
1401
+ },
1402
+ {
1403
+ name: "list_individual_assignments",
1404
+ description: "List one CCB individual's serving assignments with status, schedule, and event ids.",
1405
+ annotations: readOnly,
1406
+ inputSchema: input({
1407
+ individualId: idProperty("Individual id."),
1408
+ startDate: dateProperty("Earliest assignment date, YYYY-MM-DD."),
1409
+ endDate: dateProperty("Latest assignment date, YYYY-MM-DD."),
1410
+ page: PAGE,
1411
+ perPage,
1412
+ raw: RAW,
1413
+ }, ["individualId"]),
1414
+ outputSchema: listOutput("assignments", ASSIGNMENT_SCHEMA),
1415
+ // The spec declares start_date and end_date as this GET's request body
1416
+ // (getIndividualAssignments), which fetch cannot send; they ride the
1417
+ // query, as list_scheduling_categories' body fields do.
1418
+ handler: (args, ctx) => list(`/individuals/${id(args["individualId"])}/assignments`, args, ctx, "assignments", projectAssignment, {
1419
+ start_date: args["startDate"],
1420
+ end_date: args["endDate"],
1421
+ }),
1422
+ },
1423
+ {
1424
+ name: "list_individual_notes",
1425
+ description: "List the notes on one CCB individual that the connection may read, with sharing level and context. Pastoral content; reduce before returning.",
1426
+ annotations: readOnly,
1427
+ inputSchema: input({
1428
+ individualId: idProperty("Individual id."),
1429
+ context: {
1430
+ type: "string",
1431
+ enum: ["GROUP", "DEPARTMENT", "PROCESS_QUEUE", "GENERAL"],
1432
+ description: "Only notes in this context.",
1433
+ },
1434
+ page: PAGE,
1435
+ perPage,
1436
+ raw: RAW,
1437
+ }, ["individualId"]),
1438
+ outputSchema: listOutput("notes", NOTE_SCHEMA),
1439
+ handler: (args, ctx) => list(`/individuals/${id(args["individualId"])}/notes`, args, ctx, "notes", projectNote, {
1440
+ context: args["context"],
1441
+ include_creator: true,
1442
+ }),
1443
+ },
1444
+ {
1445
+ name: "get_giving_metrics",
1446
+ description: "Get gift counts by period for one CCB individual or family, over at most 12 months. Counts only; v2 exposes no amounts or transactions.",
1447
+ annotations: readOnly,
1448
+ inputSchema: input({
1449
+ individualId: idProperty("Individual id. Exactly one of individualId or familyId."),
1450
+ familyId: idProperty("Family id. Exactly one of individualId or familyId."),
1451
+ start: dateProperty("Start date, YYYY-MM-DD. CCB caps the range at 12 months."),
1452
+ end: dateProperty("End date, YYYY-MM-DD."),
1453
+ raw: RAW,
1454
+ }),
1455
+ outputSchema: obj({
1456
+ periods: {
1457
+ type: "array",
1458
+ items: obj({
1459
+ start: STR,
1460
+ count: INT,
1461
+ members: { type: "array", items: obj({ individualId: INT, count: INT }) },
1462
+ }),
1463
+ },
1464
+ }),
1465
+ handler: async (args, ctx) => {
1466
+ const individual = args["individualId"] !== undefined;
1467
+ if (individual === (args["familyId"] !== undefined)) {
1468
+ throw new ConnectorCallError("invalid_args", "Pass exactly one of individualId or familyId.");
1469
+ }
1470
+ if (typeof args["start"] === "string" &&
1471
+ typeof args["end"] === "string" &&
1472
+ Date.parse(args["end"]) - Date.parse(args["start"]) > 366 * DAY_MS) {
1473
+ throw new ConnectorCallError("invalid_args", "CCB caps giving metrics at a 12-month range; narrow start and end.");
1474
+ }
1475
+ const path = individual
1476
+ ? `/individuals/${id(args["individualId"])}/metrics/giving`
1477
+ : `/families/${id(args["familyId"])}/metrics/giving`;
1478
+ const { body } = await callCcb(send, { method: "GET", path, query: { start: args["start"], end: args["end"] } }, ctx);
1479
+ const rows = asArray(body);
1480
+ if (args["raw"] === true)
1481
+ return { periods: rows };
1482
+ return {
1483
+ periods: rows.map((value) => {
1484
+ const row = asRecord(value);
1485
+ return compact({
1486
+ start: row["start"] ?? row["date"],
1487
+ count: row["count"],
1488
+ members: Array.isArray(row["members"])
1489
+ ? row["members"].map((member) => {
1490
+ const item = asRecord(member);
1491
+ return compact({ individualId: item["id"], count: item["count"] });
1492
+ })
1493
+ : undefined,
1494
+ });
1495
+ }),
1496
+ };
1497
+ },
1498
+ },
1499
+ {
1500
+ name: "list_individual_pledges",
1501
+ description: "List one CCB individual's pledges: fund, pledged, paid, and expected amounts, and recurrence.",
1502
+ annotations: readOnly,
1503
+ inputSchema: input({ individualId: idProperty("Individual id."), page: PAGE, perPage, raw: RAW }, [
1504
+ "individualId",
1505
+ ]),
1506
+ outputSchema: listOutput("pledges", PLEDGE_SCHEMA),
1507
+ handler: (args, ctx) => list(`/individuals/${id(args["individualId"])}/pledges`, args, ctx, "pledges", projectPledge),
1508
+ },
1509
+ ];
1510
+ const hatches = [
1511
+ {
1512
+ name: "ccb_api_get",
1513
+ description: "Call any CCB v2 GET endpoint and return its untouched body. Use named reads first; they project and page for you.",
1514
+ annotations: readOnly,
1515
+ inputSchema: input({ path: HATCH_PATH, query: QUERY }, ["path"]),
1516
+ outputSchema: RESULT_OUTPUT,
1517
+ handler: async (args, ctx) => {
1518
+ const scalars = hatchQuery(args["query"]);
1519
+ const { body, headers } = await callCcb(send, { method: "GET", path: hatchPath(args["path"]), query: scalars }, ctx);
1520
+ const page = typeof scalars["page"] === "number" ? scalars["page"] : 1;
1521
+ const per = typeof scalars["per_page"] === "number" ? scalars["per_page"] : DEFAULT_PER_PAGE;
1522
+ return defined({
1523
+ result: body ?? null,
1524
+ page: Array.isArray(body) && (headers.has("x-page") || headers.has("x-next-page"))
1525
+ ? pageInfo(headers, page, per, body.length)
1526
+ : undefined,
1527
+ });
1528
+ },
1529
+ },
1530
+ {
1531
+ name: "ccb_api_search",
1532
+ description: "Run a CCB advanced search (POST /search/{domain}/results) and return untouched rows. Read-only; individuals allows one call per five seconds.",
1533
+ annotations: readOnly,
1534
+ inputSchema: input({
1535
+ // A pattern, not an enum: fifteen domain names render past the
1536
+ // 256-byte annotation budget and would truncate the whole tool in
1537
+ // search (H7). The handler refuses anything outside the list.
1538
+ domain: {
1539
+ type: "string",
1540
+ pattern: "^[a-z_]{1,40}$",
1541
+ description: `Search domain: ${SEARCH_DOMAINS.join(", ")}.`,
1542
+ },
1543
+ body: {
1544
+ type: "object",
1545
+ description: "Search body as CCB documents it for the domain, e.g. { configuration, filters }.",
1546
+ },
1547
+ page: PAGE,
1548
+ perPage,
1549
+ }, ["domain", "body"]),
1550
+ outputSchema: RESULT_OUTPUT,
1551
+ handler: async (args, ctx) => {
1552
+ if (!SEARCH_DOMAINS.includes(args["domain"])) {
1553
+ throw new ConnectorCallError("invalid_args", `CCB has no advanced search domain "${String(args["domain"]).slice(0, 40)}"; use one of ${SEARCH_DOMAINS.join(", ")}.`);
1554
+ }
1555
+ const paging = pageQuery(args, defaultPerPage);
1556
+ const { body, headers } = await callCcb(send, { method: "POST", path: `/search/${args["domain"]}/results`, query: paging, body: args["body"] }, ctx);
1557
+ return {
1558
+ result: body ?? null,
1559
+ page: pageInfo(headers, paging.page, paging.per_page, asArray(body).length),
1560
+ };
1561
+ },
1562
+ },
1563
+ ];
1564
+ if (writable) {
1565
+ hatches.push({
1566
+ name: "ccb_api_mutate",
1567
+ description: "Call any CCB v2 JSON POST, PUT, PATCH, or DELETE endpoint. The approval-gated hatch for every write; no file uploads.",
1568
+ annotations: { readOnlyHint: false, destructiveHint: true },
1569
+ inputSchema: input({
1570
+ method: {
1571
+ type: "string",
1572
+ enum: ["POST", "PUT", "PATCH", "DELETE"],
1573
+ description: "HTTP method the endpoint documents.",
1574
+ },
1575
+ path: HATCH_PATH,
1576
+ query: QUERY,
1577
+ body: {
1578
+ type: ["object", "array", "string", "number", "boolean", "null"],
1579
+ description: "JSON body exactly as CCB documents it. Omit when the endpoint takes none.",
1580
+ },
1581
+ }, ["method", "path"]),
1582
+ outputSchema: RESULT_OUTPUT,
1583
+ handler: async (args, ctx) => {
1584
+ const { body } = await callCcb(send, {
1585
+ method: args["method"],
1586
+ path: hatchPath(args["path"]),
1587
+ query: hatchQuery(args["query"]),
1588
+ ...(args["body"] !== undefined ? { body: args["body"] } : {}),
1589
+ }, ctx);
1590
+ return { result: body ?? null };
1591
+ },
1592
+ });
1593
+ }
1594
+ return [...named, ...hatches];
1595
+ }
1596
+ // ---------------------------------------------------------------------------
1597
+ // Guide
1598
+ // ---------------------------------------------------------------------------
1599
+ function routingLine(environment, mode, writable) {
1600
+ return [
1601
+ environment === "sandbox" ? "Sandbox church data" : "Production church data",
1602
+ mode === "identity"
1603
+ ? "acting as each signed-in person with their own CCB permissions (Identity Auth)"
1604
+ : "church-wide under one administrator-approved grant (System Auth)",
1605
+ writable ? "reads plus approval-gated writes" : "read-only",
1606
+ ].join(", ");
1607
+ }
1608
+ function usageGuide(purpose, environment, mode, scopes, writable, instructions) {
1609
+ const extra = instructions?.trim();
1610
+ return `# Church Community Builder (Pushpay ChMS)
1611
+
1612
+ ${routingLine(environment, mode, writable)}: ${purpose}${skill.fragments.guide_0}${mode === "identity" ? "- Results are what the signed-in person may see in CCB. A 403 means that person lacks the permission, not that the connection is broken.\n" : ""}${skill.fragments.guide_1}${writable ? " `ccb_api_mutate` reaches every POST, PUT, PATCH, and DELETE and always needs approval." : " This connection is read-only, so it ships no write tool."} Paths are below the API origin, such as \`/church/membership_types\`.
1613
+ Granted scopes: ${scopes.join(" ")}. A call outside them fails with 403.
1614
+ ${extra ? `\n## ${skill.instructionsHeading}\n\n${extra}\n` : ""}`;
1615
+ }
1616
+ // ---------------------------------------------------------------------------
1617
+ // Constructor
1618
+ // ---------------------------------------------------------------------------
1619
+ function resolveScopes(options) {
1620
+ if (options.scopes !== undefined && options.access !== undefined) {
1621
+ throw new Error("ccb() takes access or scopes, not both: scopes is the exact list and replaces access.");
1622
+ }
1623
+ if (options.scopes === undefined) {
1624
+ const access = options.access ?? "read";
1625
+ if (access !== "read" && access !== "read-write") {
1626
+ throw new Error('ccb() access must be "read" or "read-write".');
1627
+ }
1628
+ return access === "read" ? CCB_READ_SCOPES : [...CCB_READ_SCOPES, ...CCB_WRITE_SCOPES];
1629
+ }
1630
+ if (!Array.isArray(options.scopes) || options.scopes.length === 0) {
1631
+ throw new Error("ccb() scopes must be a non-empty list.");
1632
+ }
1633
+ const seen = new Set();
1634
+ for (const scope of options.scopes) {
1635
+ if (typeof scope !== "string" || !SCOPE_SHAPE.test(scope)) {
1636
+ throw new Error(`ccb() scope "${String(scope)}" is not a CCB scope; CCB scopes look like read:individuals.`);
1637
+ }
1638
+ if (seen.has(scope))
1639
+ throw new Error(`ccb() scope "${scope}" is listed twice.`);
1640
+ seen.add(scope);
1641
+ }
1642
+ return [...options.scopes];
1643
+ }
1644
+ function defaultTitle(environment, mode, writable) {
1645
+ const qualifiers = [
1646
+ ...(environment === "sandbox" ? ["sandbox"] : []),
1647
+ ...(mode === "identity" ? ["per-person"] : []),
1648
+ ...(writable ? [] : ["read-only"]),
1649
+ ];
1650
+ return `Pushpay ChMS (CCB)${qualifiers.length ? ` — ${qualifiers.join(", ")}` : ""}`;
1651
+ }
1652
+ /** The closed options ccb() accepts; see `assertKnownOptions`. */
1653
+ const CCB_OPTIONS = optionsOf()({
1654
+ ...keys("purpose", "title", "instructions", "environment", "mode", "clientId", "clientSecret", "access", "scopes", "subdomain", "defaultPerPage", "maxResultBytes"),
1655
+ callAdmission: CALL_ADMISSION,
1656
+ });
1657
+ /** A maintained Church Community Builder (Pushpay ChMS) v2 connection. */
1658
+ export const ccb = asProviderFactory({
1659
+ name: "ccb",
1660
+ title: "Church Community Builder",
1661
+ kind: "api",
1662
+ readme: "Church Community Builder",
1663
+ bundle: {
1664
+ "baselineGzip": 120479,
1665
+ "maxGzip": 180479,
1666
+ "note": "./providers/ccb starts at 120,479 B gzip: the hand-written surface plus api()'s static OAuth grant over the SDK's auth client, with no remoteMcp() transport. The cap uses the baseline + 60,000 B policy.",
1667
+ },
1668
+ skill,
1669
+ options: CCB_OPTIONS,
1670
+ create: ccbConnector,
1671
+ });
1672
+ function ccbConnector(id, options) {
1673
+ const purpose = typeof options.purpose === "string" ? options.purpose.trim() : "";
1674
+ if (!purpose)
1675
+ throw new Error("ccb() requires a non-empty church purpose.");
1676
+ if (options.environment !== "production" && options.environment !== "sandbox") {
1677
+ throw new Error('ccb() requires environment: "production" or "sandbox" — the one the OAuth client was issued for. Credentials do not cross environments.');
1678
+ }
1679
+ if (options.mode !== "system" && options.mode !== "identity") {
1680
+ throw new Error('ccb() requires mode: "system" (one church-wide grant an administrator approves) or "identity" (each person signs in as themselves).');
1681
+ }
1682
+ const subdomain = options.subdomain?.trim();
1683
+ if (subdomain !== undefined && !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(subdomain)) {
1684
+ throw new Error("ccb() subdomain must be the bare CCB subdomain, such as mychurch.");
1685
+ }
1686
+ const defaultPerPage = options.defaultPerPage ?? DEFAULT_PER_PAGE;
1687
+ if (!PER_PAGE_VALUES.includes(defaultPerPage)) {
1688
+ throw new Error("ccb() defaultPerPage must be 25, 50, 75, or 100 — the only page sizes CCB accepts.");
1689
+ }
1690
+ const scopes = resolveScopes(options);
1691
+ const writable = scopes.some((scope) => !scope.startsWith("read:"));
1692
+ const { environment, mode } = options;
1693
+ const endpoints = CCB_ENVIRONMENTS[environment];
1694
+ return api(id, {
1695
+ title: options.title ?? defaultTitle(environment, mode, writable),
1696
+ description: `Church Community Builder (Pushpay ChMS) — ${routingLine(environment, mode, writable)}: ${purpose}`,
1697
+ // System Auth is one administrator's approval for the whole church, so
1698
+ // the grant is the deployment's; a personal one would send every user to
1699
+ // find a Master Administrator. Identity Auth acts as the person who
1700
+ // signed in, so sharing it would let everyone act as whoever connected
1701
+ // first. The mode decides, and there is no separate override to contradict it.
1702
+ authScope: mode === "identity" ? "personal" : "shared",
1703
+ oauth: {
1704
+ authorizationEndpoint: endpoints.authorizationEndpoint,
1705
+ tokenEndpoint: `${endpoints.apiOrigin}/oauth/token`,
1706
+ clientId: options.clientId,
1707
+ clientSecret: options.clientSecret,
1708
+ scope: scopes.join(" "),
1709
+ authorizationParams: {
1710
+ ...(mode === "identity" ? { resource_owner_auth: "true" } : {}),
1711
+ ...(subdomain ? { subdomain } : {}),
1712
+ },
1713
+ tokenRequestHeaders: { Accept: CCB_MEDIA_TYPE },
1714
+ apiOrigins: [endpoints.apiOrigin],
1715
+ },
1716
+ usageGuide: {
1717
+ content: usageGuide(purpose, environment, mode, scopes, writable, options.instructions),
1718
+ summary: `${environment === "sandbox" ? "Sandbox" : "Production"} CCB, ${mode === "identity" ? "per person" : "church-wide"}: id resolution, sensitive data, paging, rate limits, hatches.`,
1719
+ required: true,
1720
+ },
1721
+ callAdmission: options.callAdmission ?? CCB_ADMISSION,
1722
+ ...(options.maxResultBytes !== undefined ? { maxResultBytes: options.maxResultBytes } : {}),
1723
+ tools: tools(ccbTransport(endpoints.apiOrigin), defaultPerPage, writable),
1724
+ });
1725
+ }