@zackbart/connecta 0.28.0 → 0.28.1

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