@zackbart/connecta 0.27.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 (73) hide show
  1. package/AGENTS.md +6 -6
  2. package/CHANGELOG.md +229 -0
  3. package/README.md +23 -24
  4. package/bin/connecta.mjs +1 -5
  5. package/dist/activity.d.ts +5 -4
  6. package/dist/activity.js +1 -10
  7. package/dist/artifacts/connector.js +1 -1
  8. package/dist/artifacts/json.js +10 -0
  9. package/dist/artifacts/operations.js +1 -1
  10. package/dist/artifacts/validate.js +1 -2
  11. package/dist/auth/downstream-oauth.js +147 -28
  12. package/dist/auth/static-oauth.js +504 -0
  13. package/dist/catalog-service.js +1 -1
  14. package/dist/connectors/api-connector.d.ts +168 -0
  15. package/dist/connectors/api-connector.js +127 -0
  16. package/dist/connectors/api.d.ts +8 -71
  17. package/dist/connectors/api.js +10 -99
  18. package/dist/connectors/guarded-fetch.js +37 -3
  19. package/dist/connectors/remote-mcp.js +7 -32
  20. package/dist/execute.js +92 -368
  21. package/dist/executors/quickjs.js +1 -1
  22. package/dist/exempt-writes.js +162 -0
  23. package/dist/index.d.ts +13 -32
  24. package/dist/index.js +14 -47
  25. package/dist/invocation.js +6 -19
  26. package/dist/meta-tools.js +1 -2
  27. package/dist/oauth-partition.js +17 -0
  28. package/dist/operator-ui/generated.js +1 -1
  29. package/dist/operator-ui/model.d.ts +3 -3
  30. package/dist/operator-ui/view.js +6 -4
  31. package/dist/providers/basecamp.d.ts +65 -0
  32. package/dist/providers/basecamp.js +445 -0
  33. package/dist/providers/breeze.d.ts +34 -0
  34. package/dist/providers/breeze.js +1402 -0
  35. package/dist/providers/ccb.d.ts +103 -0
  36. package/dist/providers/ccb.js +1570 -0
  37. package/dist/providers/cloudflare.js +1 -1
  38. package/dist/providers/linear.js +1 -1
  39. package/dist/providers/mixpanel.js +1 -1
  40. package/dist/providers/notion.js +1 -1
  41. package/dist/providers/overflow.d.ts +60 -0
  42. package/dist/providers/overflow.js +1572 -0
  43. package/dist/providers/planning-center.d.ts +59 -0
  44. package/dist/providers/planning-center.js +2039 -0
  45. package/dist/providers/revenuecat.js +1 -1
  46. package/dist/providers/stripe.js +1 -1
  47. package/dist/providers/tithely.d.ts +49 -0
  48. package/dist/providers/tithely.js +1034 -0
  49. package/dist/providers/vercel.js +1 -1
  50. package/dist/routes/mcp.js +5 -21
  51. package/dist/routes/oauth.js +29 -3
  52. package/dist/routes/shared.d.ts +1 -6
  53. package/dist/server.js +0 -4
  54. package/dist/skills.js +8 -23
  55. package/dist/tool-safety.d.ts +1 -1
  56. package/dist/tool-safety.js +1 -1
  57. package/dist/types.d.ts +2 -2
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/documentation/architecture.md +15 -17
  61. package/documentation/auth.md +120 -6
  62. package/documentation/code-mode.md +91 -194
  63. package/documentation/meta-tools.md +38 -41
  64. package/ethos.md +8 -9
  65. package/examples/worker/README.md +10 -14
  66. package/examples/worker/src/d1-activity-row.ts +4 -1
  67. package/examples/worker/src/index.ts +1 -9
  68. package/package.json +26 -2
  69. package/templates/node/AGENTS.md +4 -3
  70. package/templates/node/README.md +3 -4
  71. package/templates/node/package.json +1 -1
  72. package/dist/resumable.js +0 -1323
  73. package/dist/run-journal.js +0 -347
@@ -0,0 +1,1572 @@
1
+ /**
2
+ * Overflow (overflow.co) — church and nonprofit giving: cash, stock, crypto,
3
+ * and donor-advised-fund gifts, the deposits that settle them, the donors who
4
+ * gave them, and the recurring gifts that will.
5
+ *
6
+ * No SDK on purpose, and Overflow publishes none for this API anyway: v3 is
7
+ * header-authenticated JSON over thirty-eight operations, so `fetch` through
8
+ * the shared guarded transport keeps this subpath Workers-clean and adds
9
+ * nothing to install. Every tool below is hand-written. Overflow's OpenAPI
10
+ * document is drift evidence only — `scripts/drift/overflow-endpoints.json`
11
+ * records the operations the named tools touch, and
12
+ * `npm run providers:check -- --provider overflow` compares them with the
13
+ * published document without a credential. That document is served only by
14
+ * staging (`server.stage.overflow.co/api/docs/openapi.json`; production's
15
+ * equivalent answers 404), so the check reads staging's contract and assumes
16
+ * production runs the same v3 surface — Overflow documents one API at two
17
+ * base URLs, not two APIs.
18
+ *
19
+ * The surface is small enough to cover whole: named reads for the jobs a
20
+ * finance or ministry team actually asks about — what was given, what settled
21
+ * into the bank, who gave, what recurs, which campaign, and why a webhook
22
+ * missed — and two guarded hatches so every other operation stays reachable.
23
+ * Every write Overflow offers either moves money (charges, payment
24
+ * authorization, refunds, recurring-gift changes) or edits a donor's record,
25
+ * so none gets a named tool: each crosses `overflow_api_mutate`, which is
26
+ * destructive by declaration and lands on the host's approval prompt with the
27
+ * exact method, path, and body a human is being asked to allow. Overflow has
28
+ * no upload endpoint, so there is no upload hatch.
29
+ */
30
+ import { apiConnector as api, defined } from "../connectors/api-connector.js";
31
+ import { guardedFetch, retryAfterMs, } from "../connectors/guarded-fetch.js";
32
+ import { ConnectorCallError } from "../errors.js";
33
+ /**
34
+ * Overflow's two published environments. They are separate deployments with
35
+ * separate credentials: a staging key works only against staging and a
36
+ * production key only against production.
37
+ */
38
+ export const OVERFLOW_API_BASE_URLS = {
39
+ production: "https://server.overflow.co",
40
+ staging: "https://server.stage.overflow.co",
41
+ };
42
+ /** Every v3 operation lives below this path on either origin. */
43
+ const API_PATH = "/api/v3";
44
+ /** Overflow's cap on `limit` for every page-numbered collection. */
45
+ const MAX_PAGE_SIZE = 100;
46
+ /** Overflow's own default; already a quarter of the cap, so a first read is cheap. */
47
+ const DEFAULT_PAGE_SIZE = 25;
48
+ /** Webhook event logs page by cursor and cap `limit` lower, at 50. */
49
+ const MAX_EVENT_LOG_PAGE_SIZE = 50;
50
+ const DEFAULT_EVENT_LOG_PAGE_SIZE = 25;
51
+ /**
52
+ * A ceiling on absurdity, not a budget. A full page of 100 contributions with
53
+ * their embedded donor blocks is a few hundred kilobytes; anything near four
54
+ * mebibytes is a proxy misbehaving.
55
+ */
56
+ const OVERFLOW_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
57
+ /** Overflow's documented fixed window: 120 requests per 60 seconds per client. */
58
+ const RATE_LIMIT_WINDOW_MS = 60_000;
59
+ /**
60
+ * Overflow documents 120 requests per minute per API client, metered in a
61
+ * fixed 60-second window that opens on the first request
62
+ * (docs.overflow.co/api-reference/rate-limiting). A rolling 60-second window
63
+ * of 120 calls is the conservative reading of that: no 60-second span it
64
+ * admits can exceed the fixed window's allowance. It is still a per-runtime
65
+ * approximation, not an enforcement — every isolate or process keeps its own
66
+ * counter, and any other integration using the same client id spends the same
67
+ * allowance without this counter seeing it. `maxConcurrency: 4` is connecta's
68
+ * choice rather than Overflow's: an averaged budget cannot stop one
69
+ * `execute_code` program from firing a whole minute's allowance in one tick,
70
+ * and the concurrency bound is what keeps a fan-out from tripping the 429.
71
+ */
72
+ const OVERFLOW_ADMISSION = {
73
+ rules: [
74
+ {
75
+ maxConcurrency: 4,
76
+ budget: { kind: "rolling-window", maxCalls: 120, windowMs: RATE_LIMIT_WINDOW_MS },
77
+ maxQueueSize: 32,
78
+ queueTimeoutMs: 5_000,
79
+ retryAfterMs: 1_000,
80
+ },
81
+ ],
82
+ };
83
+ function asRecord(value) {
84
+ return value && typeof value === "object" && !Array.isArray(value)
85
+ ? value
86
+ : {};
87
+ }
88
+ function asArray(value) {
89
+ return Array.isArray(value) ? value : [];
90
+ }
91
+ function compact(value) {
92
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
93
+ }
94
+ /** A `{ id, name }` reference, or undefined when Overflow sent none. */
95
+ function reference(value) {
96
+ const record = asRecord(value);
97
+ return Object.keys(record).length === 0
98
+ ? undefined
99
+ : compact({ id: record["id"], name: record["name"] });
100
+ }
101
+ // --- Credentials and transport ----------------------------------------------
102
+ const API_KEY_CREDENTIAL = {
103
+ label: "Overflow API credentials",
104
+ description: "The client id and API key Overflow issued for this environment (Overflow dashboard, or Overflow's developer team). Staging and production credentials are separate and not interchangeable. Server-side only: connecta sends them to Overflow's API and nowhere else.",
105
+ fields: [
106
+ {
107
+ name: "clientId",
108
+ label: "Client ID",
109
+ description: "Overflow's public client identifier, sent as x-client-id.",
110
+ placeholder: "c77c8d57-7e1c-4a7e-ad46-e756e5f8dabd",
111
+ inputType: "text",
112
+ },
113
+ {
114
+ name: "apiKey",
115
+ label: "API key",
116
+ description: "The secret key paired with that client id, sent as x-api-key.",
117
+ placeholder: "Paste Overflow API key",
118
+ inputType: "password",
119
+ },
120
+ ],
121
+ };
122
+ /**
123
+ * One credential source today: the operator's client id and API key. This is
124
+ * the only place that knows what proves identity to Overflow, so an OAuth
125
+ * bearer source — if Overflow ever offers one and `api()` grows the slot — is
126
+ * a second function selected at construction, not a change to any tool.
127
+ */
128
+ async function apiKeyHeaders(ctx) {
129
+ const values = await ctx.credential?.getAll();
130
+ const clientId = values?.["clientId"]?.trim();
131
+ const apiKey = values?.["apiKey"]?.trim();
132
+ if (!clientId || !apiKey) {
133
+ throw new ConnectorCallError("auth_required", "No Overflow client id and API key are configured for this connector. Call authorize_connector for recovery options; an operator adds both fields in this connection in the operator UI.");
134
+ }
135
+ return { "x-client-id": clientId, "x-api-key": apiKey };
136
+ }
137
+ function overflowTransport(origin) {
138
+ return guardedFetch({
139
+ provider: "Overflow",
140
+ baseUrl: `${origin.replace(/\/+$/, "")}${API_PATH}`,
141
+ headers: { Accept: "application/json" },
142
+ maxResponseBytes: OVERFLOW_MAX_RESPONSE_BYTES,
143
+ authenticate: apiKeyHeaders,
144
+ });
145
+ }
146
+ // --- Failures ----------------------------------------------------------------
147
+ /** NestJS framing: `{ statusCode, message: string | string[], error }`, or `{ error }`. */
148
+ function detailFor(payload, status) {
149
+ const root = asRecord(payload);
150
+ const message = root["message"];
151
+ if (typeof message === "string" && message.trim())
152
+ return message.trim();
153
+ if (Array.isArray(message)) {
154
+ const lines = message.filter((line) => typeof line === "string" && line.trim() !== "");
155
+ if (lines.length > 0)
156
+ return lines.join("; ");
157
+ }
158
+ if (typeof root["error"] === "string" && root["error"].trim()) {
159
+ return root["error"].trim();
160
+ }
161
+ if (typeof payload === "string" && payload.trim()) {
162
+ return payload.trim().slice(0, 300);
163
+ }
164
+ return `Overflow returned HTTP ${status}.`;
165
+ }
166
+ /**
167
+ * Overflow's own wait: `Retry-After` if a proxy supplies one, otherwise
168
+ * `x-ratelimit-reset`, which Overflow documents as the seconds left in the
169
+ * current window (a delta, not an epoch).
170
+ */
171
+ function windowResetMs(headers) {
172
+ const retryAfter = retryAfterMs(headers);
173
+ if (retryAfter !== undefined)
174
+ return retryAfter;
175
+ const raw = headers.get("x-ratelimit-reset");
176
+ if (!raw)
177
+ return undefined;
178
+ const seconds = Number(raw.trim());
179
+ return Number.isFinite(seconds) && seconds >= 0
180
+ ? Math.trunc(seconds * 1_000)
181
+ : undefined;
182
+ }
183
+ /**
184
+ * Map an Overflow failure by what the caller does next (H11).
185
+ *
186
+ * Three readings here are Overflow-specific. A bad client id or key answers
187
+ * 403 "Forbidden resource" (observed against staging; the docs table says
188
+ * 401), and so does an attempt to edit a donor profile that a donor account
189
+ * owns — the status cannot tell those apart, so a write's message says so.
190
+ * A 404 is an absence: credentials are per nonprofit and Overflow scopes every
191
+ * read to it, so a missing id is missing from this nonprofit's view and the
192
+ * next move is to re-resolve it. And a 5xx on a write is never retryable from
193
+ * here: Overflow's own contract returns 500 from `POST /contributions` after
194
+ * work has started, so the charge may exist, and a blind retry could charge a
195
+ * donor twice.
196
+ */
197
+ function overflowFailure(status, headers, payload, method) {
198
+ const detail = detailFor(payload, status);
199
+ if (status === 429) {
200
+ return new ConnectorCallError("rate_limited", `Overflow rate limit reached (HTTP 429): ${detail} Overflow allows 120 requests per minute per API client in a fixed 60-second window, shared by every integration using this client id.`, { retryAfterMs: windowResetMs(headers) ?? RATE_LIMIT_WINDOW_MS });
201
+ }
202
+ if (status === 401 || status === 403) {
203
+ const ambiguity = method !== "GET" && status === 403
204
+ ? " Overflow also answers 403 when a donor profile owned by a donor account cannot be edited through the API; if reads succeed with this credential, that refusal is the cause and no credential change will fix it."
205
+ : "";
206
+ return new ConnectorCallError("auth_required", `Overflow rejected the request (HTTP ${status}): ${detail} The client id and API key may be wrong, revoked, or issued for the other environment (staging and production credentials are not interchangeable); an operator must replace them.${ambiguity}`);
207
+ }
208
+ if (status === 404) {
209
+ return new ConnectorCallError("not_found", `Overflow found no such resource for this nonprofit (HTTP 404): ${detail} Re-resolve the id with its list tool; for a raw call, confirm the path below /api/v3.`);
210
+ }
211
+ if (status === 400) {
212
+ return new ConnectorCallError("invalid_args", `Overflow rejected the request (HTTP 400): ${detail}`);
213
+ }
214
+ if (status === 409 || status === 422) {
215
+ return new ConnectorCallError("connector_call_failed", `Overflow refused the operation (HTTP ${status}): ${detail} Repeating the same call will not change the outcome.`, { retryable: false });
216
+ }
217
+ if (status >= 500) {
218
+ if (method !== "GET") {
219
+ return new ConnectorCallError("connector_call_failed", `Overflow failed while processing a write (HTTP ${status}): ${detail} The write may or may not have taken effect. Read the affected contribution, donor, or recurring gift before retrying; repeating a charge, refund, or subscription change can apply it twice.`, { retryable: false });
220
+ }
221
+ const wait = windowResetMs(headers);
222
+ return new ConnectorCallError("unavailable", `Overflow is failing upstream (HTTP ${status}): ${detail}`, wait === undefined ? {} : { retryAfterMs: wait });
223
+ }
224
+ return new ConnectorCallError("connector_call_failed", `Overflow request failed (HTTP ${status}): ${detail}`, { retryable: false });
225
+ }
226
+ function parseBody(text) {
227
+ if (!text.trim())
228
+ return undefined;
229
+ try {
230
+ return JSON.parse(text);
231
+ }
232
+ catch {
233
+ return text;
234
+ }
235
+ }
236
+ /**
237
+ * Send one request. Named tools read strictly — a successful response that is
238
+ * not JSON is a malformed answer — while the hatches return Overflow's body
239
+ * as it arrived, text included (`/health` answers `ok`).
240
+ */
241
+ async function callOverflow(send, request, ctx, mode = "strict") {
242
+ return await send(request, ctx, async (response) => {
243
+ const text = await response.text();
244
+ if (!response.ok) {
245
+ throw overflowFailure(response.status, response.headers, parseBody(text), request.method);
246
+ }
247
+ const body = parseBody(text);
248
+ if (mode === "strict" && typeof body === "string") {
249
+ throw new ConnectorCallError("connector_call_failed", "Overflow returned a non-JSON body for a successful read.", { retryable: false });
250
+ }
251
+ return body;
252
+ });
253
+ }
254
+ function segment(value) {
255
+ return encodeURIComponent(String(value));
256
+ }
257
+ /**
258
+ * Overflow pages by number and reports `totalCount`, so "is there another
259
+ * page?" is arithmetic over what was asked for. A response with no usable
260
+ * count reports `hasMore` from whether the page came back full — honest in
261
+ * the common case, and never a confident `false` on a page that was full.
262
+ */
263
+ function pageInfo(payload, page, limit, rows) {
264
+ const total = asRecord(payload)["totalCount"];
265
+ if (typeof total === "number" && Number.isFinite(total)) {
266
+ return { page, limit, totalCount: total, hasMore: page * limit < total };
267
+ }
268
+ return { page, limit, totalCount: (page - 1) * limit + rows, hasMore: rows >= limit };
269
+ }
270
+ // --- Projections -------------------------------------------------------------
271
+ /**
272
+ * The donor as it rides inside a contribution: identity only. Overflow embeds
273
+ * the whole profile — email, phone, mailing address, giving aggregates — in
274
+ * every contribution row; `get_donor` is where that belongs.
275
+ */
276
+ function projectDonorReference(value) {
277
+ const donor = asRecord(value);
278
+ if (Object.keys(donor).length === 0)
279
+ return undefined;
280
+ return compact({
281
+ id: donor["id"],
282
+ firstName: donor["firstName"],
283
+ lastName: donor["lastName"],
284
+ });
285
+ }
286
+ function projectContribution(value) {
287
+ const contribution = asRecord(value);
288
+ const paymentMethod = asRecord(contribution["paymentMethod"]);
289
+ const stocks = asRecord(contribution["stocks"]);
290
+ const crypto = asRecord(contribution["crypto"]);
291
+ return compact({
292
+ id: contribution["id"],
293
+ type: contribution["type"],
294
+ status: contribution["status"],
295
+ amount: contribution["amount"],
296
+ frequency: contribution["frequency"] ?? undefined,
297
+ contributionDate: contribution["contributionDate"],
298
+ donor: projectDonorReference(contribution["donor"]),
299
+ anonymous: contribution["anonymous"],
300
+ donorCoveredFees: contribution["donorCoveredFees"],
301
+ campaign: reference(contribution["campaign"]),
302
+ subcampaign: reference(contribution["subcampaign"]),
303
+ locationId: contribution["locationId"],
304
+ depositId: contribution["depositId"] ?? undefined,
305
+ subscriptionId: contribution["subscriptionId"],
306
+ givingLinkId: contribution["givingLinkId"] ?? undefined,
307
+ pledgeId: contribution["pledgeId"] ?? undefined,
308
+ paymentMethod: Object.keys(paymentMethod).length === 0
309
+ ? undefined
310
+ : compact({ type: paymentMethod["type"], last4: paymentMethod["last4"] }),
311
+ stocks: Object.keys(stocks).length === 0
312
+ ? undefined
313
+ : compact({ quantity: stocks["quantity"], tickers: stocks["tickers"] }),
314
+ crypto: Object.keys(crypto).length === 0
315
+ ? undefined
316
+ : compact({ quantity: crypto["quantity"], token: crypto["token"] }),
317
+ dedication: contribution["dedication"] ?? undefined,
318
+ donorNotes: contribution["donorNotes"] ?? undefined,
319
+ metadata: contribution["metadata"] ?? undefined,
320
+ createdAt: contribution["createdAt"],
321
+ updatedAt: contribution["updatedAt"],
322
+ });
323
+ }
324
+ function projectDonor(value, full) {
325
+ const donor = asRecord(value);
326
+ return compact({
327
+ id: donor["id"],
328
+ firstName: donor["firstName"],
329
+ lastName: donor["lastName"],
330
+ email: donor["email"],
331
+ phone: full ? donor["phone"] : undefined,
332
+ address: full ? donor["address"] ?? undefined : undefined,
333
+ locationIds: donor["locationIds"],
334
+ totalContributionsCount: donor["totalContributionsCount"],
335
+ activeRecurringCount: donor["activeRecurringCount"],
336
+ latestContributionDate: donor["latestContributionDate"],
337
+ createdAt: donor["createdAt"],
338
+ updatedAt: donor["updatedAt"],
339
+ });
340
+ }
341
+ function projectDeposit(value, withLineItems) {
342
+ const deposit = asRecord(value);
343
+ return compact({
344
+ id: deposit["id"],
345
+ status: deposit["status"],
346
+ type: deposit["type"],
347
+ amountInCents: deposit["amountInCents"],
348
+ arrivalAt: deposit["arrivalAt"],
349
+ name: deposit["name"] ?? undefined,
350
+ bankName: deposit["bankName"] ?? undefined,
351
+ bankLast4: deposit["bankLast4"] ?? undefined,
352
+ statementDescriptor: deposit["statementDescriptor"] ?? undefined,
353
+ paymentMethodTypes: deposit["paymentMethodType"],
354
+ startingBalanceInCents: deposit["startingBalanceInCents"] ?? undefined,
355
+ endingBalanceInCents: deposit["endingBalanceInCents"] ?? undefined,
356
+ reconciledAt: deposit["reconciledAt"] ?? null,
357
+ createdAt: deposit["createdAt"],
358
+ updatedAt: deposit["updatedAt"],
359
+ lineItems: withLineItems
360
+ ? asArray(deposit["lineItems"]).map((row) => {
361
+ const item = asRecord(row);
362
+ return compact({
363
+ type: item["type"],
364
+ grossValueInCents: item["grossValueInCents"],
365
+ grossFeeValueInCents: item["grossFeeValueInCents"],
366
+ referenceId: item["referenceId"],
367
+ description: item["description"],
368
+ });
369
+ })
370
+ : undefined,
371
+ });
372
+ }
373
+ function projectRefund(value) {
374
+ const refund = asRecord(value);
375
+ return compact({
376
+ id: refund["id"],
377
+ contributionId: refund["contributionId"],
378
+ status: refund["status"],
379
+ reason: refund["reason"] ?? undefined,
380
+ amountInCents: refund["amountInCents"],
381
+ netValueInCents: refund["netValueInCents"],
382
+ feeValueInCents: refund["feeValueInCents"] ?? undefined,
383
+ depositId: refund["depositId"] ?? undefined,
384
+ createdAt: refund["createdAt"],
385
+ updatedAt: refund["updatedAt"],
386
+ });
387
+ }
388
+ function projectChargeback(value) {
389
+ const chargeback = asRecord(value);
390
+ return compact({
391
+ id: chargeback["id"],
392
+ contributionId: chargeback["contributionId"],
393
+ status: chargeback["status"],
394
+ type: chargeback["type"],
395
+ reason: chargeback["reason"] ?? undefined,
396
+ amountInCents: chargeback["amountInCents"],
397
+ netValueInCents: chargeback["netValueInCents"] ?? undefined,
398
+ feeValueInCents: chargeback["feeValueInCents"] ?? undefined,
399
+ depositId: chargeback["depositId"] ?? undefined,
400
+ createdAt: chargeback["createdAt"],
401
+ updatedAt: chargeback["updatedAt"],
402
+ });
403
+ }
404
+ function projectSubscription(value) {
405
+ const subscription = asRecord(value);
406
+ const paymentMethod = asRecord(subscription["paymentMethod"]);
407
+ return compact({
408
+ id: subscription["id"],
409
+ donorId: subscription["donorId"],
410
+ status: subscription["status"],
411
+ amount: subscription["amount"],
412
+ frequency: subscription["frequency"],
413
+ startDate: subscription["startDate"],
414
+ nextPaymentDate: subscription["nextPaymentDate"] ?? null,
415
+ anonymous: subscription["anonymous"],
416
+ donorCoveredFees: subscription["donorCoveredFees"],
417
+ campaign: reference(subscription["campaign"]),
418
+ subcampaign: reference(subscription["subcampaign"]),
419
+ locationId: subscription["locationId"] ?? undefined,
420
+ paymentMethod: Object.keys(paymentMethod).length === 0
421
+ ? undefined
422
+ : compact({
423
+ id: paymentMethod["id"],
424
+ type: paymentMethod["type"],
425
+ last4: paymentMethod["last4"],
426
+ expiration: paymentMethod["expiration"],
427
+ }),
428
+ metadata: subscription["metadata"] ?? undefined,
429
+ createdAt: subscription["createdAt"],
430
+ updatedAt: subscription["updatedAt"],
431
+ });
432
+ }
433
+ function projectPaymentMethod(value) {
434
+ const method = asRecord(value);
435
+ return compact({
436
+ id: method["id"],
437
+ type: method["type"],
438
+ last4: method["last4"],
439
+ expirationDate: method["expirationDate"],
440
+ isExpired: method["isExpired"],
441
+ updatedAt: method["updatedAt"],
442
+ });
443
+ }
444
+ function projectCampaign(value) {
445
+ const campaign = asRecord(value);
446
+ const refs = (list) => {
447
+ const rows = asArray(list).map(reference).filter((row) => row !== undefined);
448
+ return rows.length === 0 ? undefined : rows;
449
+ };
450
+ return compact({
451
+ id: campaign["id"],
452
+ name: campaign["name"],
453
+ status: campaign["status"],
454
+ isSubcampaign: campaign["isSubcampaign"],
455
+ isActive: campaign["isActive"],
456
+ startDate: campaign["startDate"],
457
+ endDate: campaign["endDate"] ?? null,
458
+ hideFromDonors: campaign["hideFromDonors"],
459
+ archivedAt: campaign["archivedAt"] ?? undefined,
460
+ locationIds: campaign["locationIds"],
461
+ parentCampaigns: refs(campaign["parentCampaigns"]),
462
+ subcampaigns: refs(campaign["subcampaigns"]),
463
+ totalContributionCount: campaign["totalContributionCount"],
464
+ totalContributionValue: campaign["totalContributionValue"],
465
+ uniqueDonorCount: campaign["uniqueDonorCount"],
466
+ });
467
+ }
468
+ function projectLocation(value) {
469
+ const location = asRecord(value);
470
+ return compact({
471
+ id: location["id"],
472
+ name: location["name"],
473
+ isDefaultLocation: location["isDefaultLocation"],
474
+ hideFromDonors: location["hideFromDonors"],
475
+ archivedAt: location["archivedAt"] ?? null,
476
+ createdAt: location["createdAt"],
477
+ updatedAt: location["updatedAt"],
478
+ });
479
+ }
480
+ function projectTapEvent(value) {
481
+ const event = asRecord(value);
482
+ const device = asRecord(event["device"]);
483
+ const group = asRecord(event["group"]);
484
+ const destination = asRecord(event["destination"]);
485
+ return compact({
486
+ id: event["id"],
487
+ createdAt: event["createdAt"],
488
+ deviceId: event["deviceId"],
489
+ deviceSerialNumber: device["serialNumber"],
490
+ groupId: event["groupId"],
491
+ groupName: group["name"],
492
+ destinationId: event["destinationId"],
493
+ destinationName: destination["name"],
494
+ destinationType: destination["type"] ?? undefined,
495
+ });
496
+ }
497
+ function projectWebhook(value) {
498
+ const webhook = asRecord(value);
499
+ return compact({
500
+ id: webhook["id"],
501
+ name: webhook["name"],
502
+ description: webhook["description"] ?? undefined,
503
+ destinationUrl: webhook["destinationUrl"],
504
+ enabledEvents: webhook["enabledEvents"],
505
+ status: webhook["status"],
506
+ createdAt: webhook["createdAt"],
507
+ updatedAt: webhook["updatedAt"],
508
+ });
509
+ }
510
+ /**
511
+ * Deliberately drops `request.body` and `response.body`. The request body is
512
+ * the event payload Overflow delivered — donor names, emails, and amounts —
513
+ * and the response body is whatever the receiving server echoed. Diagnosing a
514
+ * missed delivery needs the status, the code, and the error, not the payload.
515
+ */
516
+ function projectEventLog(value) {
517
+ const log = asRecord(value);
518
+ const request = asRecord(log["request"]);
519
+ const response = asRecord(log["response"]);
520
+ return compact({
521
+ id: log["id"],
522
+ eventId: log["eventId"],
523
+ webhookEventName: log["webhookEventName"],
524
+ status: log["status"],
525
+ attemptNumber: log["attemptNumber"],
526
+ deliverySource: log["deliverySource"],
527
+ originatedAt: log["originatedAt"],
528
+ attemptedAt: log["attemptedAt"],
529
+ url: request["url"],
530
+ responseCode: response["code"],
531
+ responseDurationMs: response["durationMs"],
532
+ errorMessage: response["errorMessage"],
533
+ });
534
+ }
535
+ // --- Schema fragments --------------------------------------------------------
536
+ function namedInput(properties, required = []) {
537
+ return { type: "object", properties, required, additionalProperties: false };
538
+ }
539
+ const RAW_PROPERTY = {
540
+ type: "boolean",
541
+ description: "Return Overflow's untouched rows instead of the lean projection.",
542
+ };
543
+ const OBJECT_ID = "^[0-9a-fA-F]{24}$";
544
+ function idProperty(source) {
545
+ return {
546
+ type: "string",
547
+ pattern: OBJECT_ID,
548
+ description: `24-hex id from ${source}.`,
549
+ };
550
+ }
551
+ const DATE_PROPERTY = (meaning) => ({
552
+ type: "string",
553
+ pattern: "^\\d{4}-\\d{2}-\\d{2}",
554
+ description: `${meaning}; ISO date or date-time.`,
555
+ });
556
+ const SORT_DIRECTION = {
557
+ type: "string",
558
+ enum: ["ASC", "DESC"],
559
+ description: "Sort direction.",
560
+ };
561
+ const ID_LIST = (source) => ({
562
+ type: "array",
563
+ minItems: 1,
564
+ items: { type: "string", pattern: OBJECT_ID, description: `Id from ${source}.` },
565
+ description: `Ids from ${source}; matches any.`,
566
+ });
567
+ const PAGE_PROPERTY = {
568
+ type: "integer",
569
+ minimum: 1,
570
+ description: "1-based page number. Defaults to 1.",
571
+ };
572
+ function limitProperty(defaultPageSize) {
573
+ return {
574
+ type: "integer",
575
+ minimum: 1,
576
+ maximum: MAX_PAGE_SIZE,
577
+ description: `Rows per page, up to Overflow's cap of ${MAX_PAGE_SIZE}. Defaults to ${defaultPageSize}.`,
578
+ };
579
+ }
580
+ const PAGE_OUTPUT = {
581
+ type: "object",
582
+ properties: {
583
+ page: { type: "integer" },
584
+ limit: { type: "integer" },
585
+ totalCount: { type: "integer" },
586
+ hasMore: { type: "boolean", description: "True when page + 1 exists." },
587
+ },
588
+ required: ["page", "limit", "totalCount", "hasMore"],
589
+ };
590
+ function listOutput(key, item) {
591
+ return {
592
+ type: "object",
593
+ properties: { [key]: { type: "array", items: item }, page: PAGE_OUTPUT },
594
+ required: [key, "page"],
595
+ };
596
+ }
597
+ const REFERENCE = {
598
+ type: "object",
599
+ properties: { id: { type: "string" }, name: { type: "string" } },
600
+ };
601
+ const CONTRIBUTION_SCHEMA = {
602
+ type: "object",
603
+ properties: {
604
+ id: { type: "string" },
605
+ type: { type: "string" },
606
+ status: { type: "string" },
607
+ amount: { type: "number" },
608
+ frequency: { type: "string" },
609
+ contributionDate: { type: "string" },
610
+ donor: { type: "object" },
611
+ campaign: REFERENCE,
612
+ subcampaign: REFERENCE,
613
+ locationId: { type: "string" },
614
+ depositId: { type: "string" },
615
+ subscriptionId: { type: "string" },
616
+ paymentMethod: { type: "object" },
617
+ stocks: { type: "object" },
618
+ crypto: { type: "object" },
619
+ metadata: { type: "object" },
620
+ createdAt: { type: "string" },
621
+ updatedAt: { type: "string" },
622
+ },
623
+ required: ["id", "type", "status", "amount"],
624
+ };
625
+ const DONOR_SCHEMA = {
626
+ type: "object",
627
+ properties: {
628
+ id: { type: "string" },
629
+ firstName: { type: "string" },
630
+ lastName: { type: "string" },
631
+ email: { type: "string" },
632
+ phone: { type: "string" },
633
+ address: { type: "object" },
634
+ locationIds: { type: "array", items: { type: "string" } },
635
+ totalContributionsCount: { type: "number" },
636
+ activeRecurringCount: { type: "number" },
637
+ latestContributionDate: { type: "string" },
638
+ createdAt: { type: "string" },
639
+ updatedAt: { type: "string" },
640
+ },
641
+ required: ["id"],
642
+ };
643
+ const DEPOSIT_SCHEMA = {
644
+ type: "object",
645
+ properties: {
646
+ id: { type: "string" },
647
+ status: { type: "string" },
648
+ type: { type: "string" },
649
+ amountInCents: { type: "number" },
650
+ arrivalAt: { type: "string" },
651
+ name: { type: "string" },
652
+ bankName: { type: "string" },
653
+ bankLast4: { type: "string" },
654
+ paymentMethodTypes: { type: "array", items: { type: "string" } },
655
+ reconciledAt: { type: ["string", "null"] },
656
+ lineItems: { type: "array", items: { type: "object" } },
657
+ createdAt: { type: "string" },
658
+ updatedAt: { type: "string" },
659
+ },
660
+ required: ["id", "status", "amountInCents"],
661
+ };
662
+ const MONEY_ROLLUP = {
663
+ type: "object",
664
+ properties: {
665
+ count: { type: "number" },
666
+ grossInCents: { type: "number" },
667
+ feesInCents: { type: "number" },
668
+ totalInCents: { type: "number" },
669
+ },
670
+ };
671
+ const REFUND_SCHEMA = {
672
+ type: "object",
673
+ properties: {
674
+ id: { type: "string" },
675
+ contributionId: { type: "string" },
676
+ status: { type: "string" },
677
+ reason: { type: "string" },
678
+ amountInCents: { type: "number" },
679
+ netValueInCents: { type: "number" },
680
+ feeValueInCents: { type: "number" },
681
+ depositId: { type: "string" },
682
+ createdAt: { type: "string" },
683
+ updatedAt: { type: "string" },
684
+ },
685
+ required: ["id", "contributionId", "status", "amountInCents"],
686
+ };
687
+ const CHARGEBACK_SCHEMA = {
688
+ type: "object",
689
+ properties: {
690
+ id: { type: "string" },
691
+ contributionId: { type: "string" },
692
+ status: { type: "string" },
693
+ type: { type: "string" },
694
+ reason: { type: "string" },
695
+ amountInCents: { type: "number" },
696
+ netValueInCents: { type: "number" },
697
+ depositId: { type: "string" },
698
+ createdAt: { type: "string" },
699
+ updatedAt: { type: "string" },
700
+ },
701
+ required: ["id", "contributionId", "status", "amountInCents"],
702
+ };
703
+ const SUBSCRIPTION_SCHEMA = {
704
+ type: "object",
705
+ properties: {
706
+ id: { type: "string" },
707
+ donorId: { type: "string" },
708
+ status: { type: "string" },
709
+ amount: { type: "number" },
710
+ frequency: { type: "string" },
711
+ startDate: { type: "string" },
712
+ nextPaymentDate: { type: ["string", "null"] },
713
+ campaign: REFERENCE,
714
+ subcampaign: REFERENCE,
715
+ locationId: { type: "string" },
716
+ paymentMethod: { type: "object" },
717
+ createdAt: { type: "string" },
718
+ updatedAt: { type: "string" },
719
+ },
720
+ required: ["id", "donorId", "status", "amount", "frequency"],
721
+ };
722
+ const PAYMENT_METHOD_SCHEMA = {
723
+ type: "object",
724
+ properties: {
725
+ id: { type: "string" },
726
+ type: { type: "string" },
727
+ last4: { type: "string" },
728
+ expirationDate: { type: "string" },
729
+ isExpired: { type: "boolean" },
730
+ updatedAt: { type: "string" },
731
+ },
732
+ required: ["id", "type", "last4"],
733
+ };
734
+ const CAMPAIGN_SCHEMA = {
735
+ type: "object",
736
+ properties: {
737
+ id: { type: "string" },
738
+ name: { type: "string" },
739
+ status: { type: "string" },
740
+ isSubcampaign: { type: "boolean" },
741
+ isActive: { type: "boolean" },
742
+ startDate: { type: "string" },
743
+ endDate: { type: ["string", "null"] },
744
+ locationIds: { type: "array", items: { type: "string" } },
745
+ parentCampaigns: { type: "array", items: REFERENCE },
746
+ subcampaigns: { type: "array", items: REFERENCE },
747
+ totalContributionCount: { type: "number" },
748
+ totalContributionValue: { type: "number" },
749
+ uniqueDonorCount: { type: "number" },
750
+ },
751
+ required: ["id", "name", "status"],
752
+ };
753
+ const LOCATION_SCHEMA = {
754
+ type: "object",
755
+ properties: {
756
+ id: { type: "string" },
757
+ name: { type: "string" },
758
+ isDefaultLocation: { type: "boolean" },
759
+ hideFromDonors: { type: "boolean" },
760
+ archivedAt: { type: ["string", "null"] },
761
+ createdAt: { type: "string" },
762
+ },
763
+ required: ["id", "name"],
764
+ };
765
+ const TAP_EVENT_SCHEMA = {
766
+ type: "object",
767
+ properties: {
768
+ id: { type: "string" },
769
+ createdAt: { type: "string" },
770
+ deviceId: { type: "string" },
771
+ deviceSerialNumber: { type: "number" },
772
+ groupId: { type: "string" },
773
+ groupName: { type: "string" },
774
+ destinationId: { type: "string" },
775
+ destinationName: { type: "string" },
776
+ destinationType: { type: "string" },
777
+ },
778
+ required: ["id", "createdAt"],
779
+ };
780
+ const WEBHOOK_SCHEMA = {
781
+ type: "object",
782
+ properties: {
783
+ id: { type: "string" },
784
+ name: { type: "string" },
785
+ destinationUrl: { type: "string" },
786
+ enabledEvents: { type: "array", items: { type: "string" } },
787
+ status: { type: "string" },
788
+ createdAt: { type: "string" },
789
+ },
790
+ required: ["id", "name", "status"],
791
+ };
792
+ const EVENT_LOG_SCHEMA = {
793
+ type: "object",
794
+ properties: {
795
+ id: { type: "string" },
796
+ eventId: { type: "string" },
797
+ webhookEventName: { type: "string" },
798
+ status: { type: "string" },
799
+ attemptNumber: { type: "number" },
800
+ attemptedAt: { type: "string" },
801
+ url: { type: "string" },
802
+ responseCode: { type: "number" },
803
+ errorMessage: { type: "string" },
804
+ },
805
+ required: ["id", "status"],
806
+ };
807
+ // --- Raw hatches ---------------------------------------------------------------
808
+ const RAW_PATH_PROPERTY = {
809
+ type: "string",
810
+ minLength: 1,
811
+ description: "Path below /api/v3 beginning with '/', e.g. /refunds/<id>. No query string.",
812
+ };
813
+ const RAW_QUERY_PROPERTY = {
814
+ type: "array",
815
+ maxItems: 50,
816
+ description: "Query parameters as name/value pairs; repeat a name to send several values.",
817
+ items: {
818
+ type: "object",
819
+ properties: {
820
+ name: { type: "string", minLength: 1, description: "Parameter name, e.g. statusBucket[]." },
821
+ value: {
822
+ type: ["string", "number", "boolean"],
823
+ description: "Parameter value.",
824
+ },
825
+ },
826
+ required: ["name", "value"],
827
+ additionalProperties: false,
828
+ },
829
+ };
830
+ /** Name/value pairs to a query, repeated names becoming repeated keys. */
831
+ function rawQuery(value) {
832
+ const query = {};
833
+ for (const row of asArray(value)) {
834
+ const pair = asRecord(row);
835
+ const name = pair["name"];
836
+ const item = pair["value"];
837
+ if (typeof name !== "string")
838
+ continue;
839
+ if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") {
840
+ (query[name] ??= []).push(item);
841
+ }
842
+ }
843
+ return Object.fromEntries(Object.entries(query).map(([name, values]) => [
844
+ name,
845
+ values.length === 1 ? values[0] : values,
846
+ ]));
847
+ }
848
+ function tools(send, defaultPageSize) {
849
+ const readOnly = { readOnlyHint: true };
850
+ const LIMIT = limitProperty(defaultPageSize);
851
+ /** One page-numbered collection read: `{ data, totalCount }` in, `{ <key>, page }` out. */
852
+ async function list(spec, ctx) {
853
+ const page = spec.args["page"] ?? 1;
854
+ const limit = spec.args["limit"] ?? defaultPageSize;
855
+ const payload = await callOverflow(send, { method: "GET", path: spec.path, query: { ...spec.query, page, limit } }, ctx);
856
+ const rows = asArray(asRecord(payload)["data"]);
857
+ return {
858
+ [spec.key]: spec.args["raw"] === true ? rows : rows.map(spec.project),
859
+ page: pageInfo(payload, page, limit, rows.length),
860
+ };
861
+ }
862
+ /** One single-resource read: Overflow wraps it as `{ data }`. */
863
+ async function one(path, ctx) {
864
+ const payload = await callOverflow(send, { method: "GET", path }, ctx);
865
+ const record = asRecord(payload)["data"];
866
+ if (record === undefined || record === null) {
867
+ throw new ConnectorCallError("connector_call_failed", "Overflow answered without the requested record.", { retryable: false });
868
+ }
869
+ return asRecord(record);
870
+ }
871
+ return [
872
+ {
873
+ name: "overflow_api_get",
874
+ description: "Call any Overflow v3 GET endpoint and return its untouched body. Prefer a named read; this covers single refunds, chargebacks, campaigns, and Tap devices.",
875
+ annotations: readOnly,
876
+ inputSchema: namedInput({ path: RAW_PATH_PROPERTY, query: RAW_QUERY_PROPERTY }, ["path"]),
877
+ outputSchema: {
878
+ type: "object",
879
+ properties: { result: { description: "Overflow's untouched response body." } },
880
+ required: ["result"],
881
+ },
882
+ handler: async (args, ctx) => ({
883
+ result: (await callOverflow(send, { method: "GET", path: String(args["path"]), query: rawQuery(args["query"]) }, ctx, "raw")) ?? null,
884
+ }),
885
+ },
886
+ {
887
+ name: "overflow_api_mutate",
888
+ description: "Send a JSON POST, PATCH, or DELETE to Overflow v3. Every Overflow write — charges, refunds, recurring gifts, donor edits — goes here, behind approval.",
889
+ annotations: { readOnlyHint: false, destructiveHint: true },
890
+ inputSchema: namedInput({
891
+ method: {
892
+ type: "string",
893
+ enum: ["POST", "PATCH", "DELETE"],
894
+ description: "Mutation method the Overflow endpoint requires.",
895
+ },
896
+ path: RAW_PATH_PROPERTY,
897
+ query: RAW_QUERY_PROPERTY,
898
+ body: {
899
+ type: ["object", "array"],
900
+ description: "JSON body exactly as Overflow documents it. Units differ: subscription `amount` is dollars, " +
901
+ "/payments/authorize takes `amountInCents`, /contributions `amount` states none. Omit when the endpoint takes none.",
902
+ },
903
+ }, ["method", "path"]),
904
+ outputSchema: {
905
+ type: "object",
906
+ properties: {
907
+ result: { description: "Overflow's untouched response body, or null when it sent none." },
908
+ },
909
+ required: ["result"],
910
+ },
911
+ handler: async (args, ctx) => ({
912
+ result: (await callOverflow(send, {
913
+ method: args["method"],
914
+ path: String(args["path"]),
915
+ query: rawQuery(args["query"]),
916
+ ...(args["body"] !== undefined ? { body: args["body"] } : {}),
917
+ }, ctx, "raw")) ?? null,
918
+ }),
919
+ },
920
+ {
921
+ name: "list_contributions",
922
+ description: "List gifts (cash, stock, crypto, DAF, manual) with status, amount, campaign, deposit, and donor name. Donor contact details stay in get_donor.",
923
+ annotations: readOnly,
924
+ inputSchema: namedInput({
925
+ minimumUpdatedDate: DATE_PROPERTY("Updated on or after"),
926
+ maximumUpdatedDate: DATE_PROPERTY("Updated on or before"),
927
+ minimumInitiatedDate: DATE_PROPERTY("Initiated on or after"),
928
+ maximumInitiatedDate: DATE_PROPERTY("Initiated on or before"),
929
+ statusBucket: {
930
+ type: "array",
931
+ minItems: 1,
932
+ uniqueItems: true,
933
+ items: { type: "string", enum: ["PENDING", "CONFIRMED", "FAILED", "CANCELED"], description: "Status bucket." },
934
+ description: "Overflow's coarse status buckets; matches any.",
935
+ },
936
+ campaignId: idProperty("list_campaigns"),
937
+ subcampaignId: idProperty("list_campaigns with isSubcampaign"),
938
+ locationIds: ID_LIST("list_locations"),
939
+ givingLinkId: { type: "string", minLength: 1, description: "Giving link id." },
940
+ pledgeId: { type: "string", minLength: 1, description: "Pledge id." },
941
+ page: PAGE_PROPERTY,
942
+ limit: LIMIT,
943
+ raw: RAW_PROPERTY,
944
+ }),
945
+ outputSchema: listOutput("contributions", CONTRIBUTION_SCHEMA),
946
+ handler: (args, ctx) => list({
947
+ args,
948
+ path: "/contributions",
949
+ key: "contributions",
950
+ project: projectContribution,
951
+ query: {
952
+ minimumUpdatedDate: args["minimumUpdatedDate"],
953
+ maximumUpdatedDate: args["maximumUpdatedDate"],
954
+ minimumInitiatedDate: args["minimumInitiatedDate"],
955
+ maximumInitiatedDate: args["maximumInitiatedDate"],
956
+ "statusBucket[]": args["statusBucket"],
957
+ campaignId: args["campaignId"],
958
+ subcampaignId: args["subcampaignId"],
959
+ "locationIds[]": args["locationIds"],
960
+ givingLinkId: args["givingLinkId"],
961
+ pledgeId: args["pledgeId"],
962
+ },
963
+ }, ctx),
964
+ },
965
+ {
966
+ name: "get_contribution",
967
+ description: "Get one contribution by id with its status, amount, campaign, deposit, and donor name. Refunds and chargebacks are separate lists.",
968
+ annotations: readOnly,
969
+ inputSchema: namedInput({ contributionId: idProperty("list_contributions"), raw: RAW_PROPERTY }, ["contributionId"]),
970
+ outputSchema: CONTRIBUTION_SCHEMA,
971
+ handler: async (args, ctx) => {
972
+ const record = await one(`/contributions/${segment(args["contributionId"])}`, ctx);
973
+ return args["raw"] === true ? record : projectContribution(record);
974
+ },
975
+ },
976
+ {
977
+ name: "list_deposits",
978
+ description: "List deposits (payouts from Overflow to the nonprofit's bank) with status, net amount in cents, arrival date, and reconciliation state.",
979
+ annotations: readOnly,
980
+ inputSchema: namedInput({
981
+ startDate: DATE_PROPERTY("Arrived on or after"),
982
+ endDate: DATE_PROPERTY("Arrived on or before"),
983
+ types: {
984
+ type: "array",
985
+ minItems: 1,
986
+ uniqueItems: true,
987
+ items: { type: "string", enum: ["AUTOMATED", "MANUAL", "IMPORT"], description: "Deposit type." },
988
+ description: "Deposit types; matches any.",
989
+ },
990
+ reconciled: { type: "boolean", description: "Filter by reconciliation state." },
991
+ minimumAmountInCents: { type: "integer", minimum: 0, description: "Net amount at least this, in cents." },
992
+ maximumAmountInCents: { type: "integer", minimum: 0, description: "Net amount at most this, in cents." },
993
+ search: { type: "string", minLength: 1, description: "Overflow's free-text search across deposit fields." },
994
+ sortDirection: { ...SORT_DIRECTION, description: "Arrival-date order. Defaults to DESC." },
995
+ page: PAGE_PROPERTY,
996
+ limit: LIMIT,
997
+ raw: RAW_PROPERTY,
998
+ }),
999
+ outputSchema: listOutput("deposits", DEPOSIT_SCHEMA),
1000
+ handler: (args, ctx) => list({
1001
+ args,
1002
+ path: "/deposits",
1003
+ key: "deposits",
1004
+ project: (row) => projectDeposit(row, false),
1005
+ query: {
1006
+ startDate: args["startDate"],
1007
+ endDate: args["endDate"],
1008
+ "types[]": args["types"],
1009
+ reconciled: args["reconciled"],
1010
+ minimumEstimatedValueInCents: args["minimumAmountInCents"],
1011
+ maximumEstimatedValueInCents: args["maximumAmountInCents"],
1012
+ search: args["search"],
1013
+ sortDirection: args["sortDirection"],
1014
+ },
1015
+ }, ctx),
1016
+ },
1017
+ {
1018
+ name: "get_deposit",
1019
+ description: "Get one deposit with its line items: each payment, refund, chargeback, adjustment, or transfer it settled, in cents. Totals are get_deposit_summary.",
1020
+ annotations: readOnly,
1021
+ inputSchema: namedInput({ depositId: idProperty("list_deposits"), raw: RAW_PROPERTY }, ["depositId"]),
1022
+ outputSchema: DEPOSIT_SCHEMA,
1023
+ handler: async (args, ctx) => {
1024
+ const record = await one(`/deposits/${segment(args["depositId"])}`, ctx);
1025
+ return args["raw"] === true ? record : projectDeposit(record, true);
1026
+ },
1027
+ },
1028
+ {
1029
+ name: "get_deposit_summary",
1030
+ description: "Get one deposit's totals by kind — contributions, refunds, chargebacks, adjustments — as count, gross, fees, and net in cents.",
1031
+ annotations: readOnly,
1032
+ inputSchema: namedInput({ depositId: idProperty("list_deposits") }, ["depositId"]),
1033
+ outputSchema: {
1034
+ type: "object",
1035
+ properties: {
1036
+ depositId: { type: "string" },
1037
+ contributions: MONEY_ROLLUP,
1038
+ refunds: MONEY_ROLLUP,
1039
+ chargebacks: MONEY_ROLLUP,
1040
+ adjustments: MONEY_ROLLUP,
1041
+ },
1042
+ required: ["depositId", "contributions", "refunds", "chargebacks", "adjustments"],
1043
+ },
1044
+ handler: async (args, ctx) => {
1045
+ const summary = await one(`/deposits/${segment(args["depositId"])}/summary`, ctx);
1046
+ const rollup = (value) => {
1047
+ const row = asRecord(value);
1048
+ return compact({
1049
+ count: row["count"] ?? 0,
1050
+ grossInCents: row["grossInCents"] ?? 0,
1051
+ feesInCents: row["feesInCents"] ?? 0,
1052
+ totalInCents: row["totalInCents"] ?? 0,
1053
+ });
1054
+ };
1055
+ return {
1056
+ depositId: args["depositId"],
1057
+ contributions: rollup(summary["contributions"]),
1058
+ refunds: rollup(summary["refunds"]),
1059
+ chargebacks: rollup(summary["chargebacks"]),
1060
+ adjustments: rollup(summary["adjustments"]),
1061
+ };
1062
+ },
1063
+ },
1064
+ {
1065
+ name: "list_refunds",
1066
+ description: "List refunds with status, reason, amount, and net in cents, optionally for one contribution. Reads only; issuing a refund is a mutate call.",
1067
+ annotations: readOnly,
1068
+ inputSchema: namedInput({
1069
+ contributionId: idProperty("list_contributions"),
1070
+ minimumUpdatedDate: DATE_PROPERTY("Updated on or after"),
1071
+ maximumUpdatedDate: DATE_PROPERTY("Updated on or before"),
1072
+ sortDirection: { ...SORT_DIRECTION, description: "Created-date order. Defaults to DESC." },
1073
+ page: PAGE_PROPERTY,
1074
+ limit: LIMIT,
1075
+ raw: RAW_PROPERTY,
1076
+ }),
1077
+ outputSchema: listOutput("refunds", REFUND_SCHEMA),
1078
+ handler: (args, ctx) => list({
1079
+ args,
1080
+ path: "/refunds",
1081
+ key: "refunds",
1082
+ project: projectRefund,
1083
+ query: {
1084
+ contributionId: args["contributionId"],
1085
+ minimumUpdatedDate: args["minimumUpdatedDate"],
1086
+ maximumUpdatedDate: args["maximumUpdatedDate"],
1087
+ sortDirection: args["sortDirection"],
1088
+ },
1089
+ }, ctx),
1090
+ },
1091
+ {
1092
+ name: "list_chargebacks",
1093
+ description: "List chargebacks (card disputes and ACH returns) with status — pending, won, lost — and amounts in cents, optionally for one contribution.",
1094
+ annotations: readOnly,
1095
+ inputSchema: namedInput({
1096
+ contributionId: idProperty("list_contributions"),
1097
+ minimumUpdatedDate: DATE_PROPERTY("Updated on or after"),
1098
+ maximumUpdatedDate: DATE_PROPERTY("Updated on or before"),
1099
+ sortDirection: { ...SORT_DIRECTION, description: "Created-date order. Defaults to DESC." },
1100
+ page: PAGE_PROPERTY,
1101
+ limit: LIMIT,
1102
+ raw: RAW_PROPERTY,
1103
+ }),
1104
+ outputSchema: listOutput("chargebacks", CHARGEBACK_SCHEMA),
1105
+ handler: (args, ctx) => list({
1106
+ args,
1107
+ path: "/chargebacks",
1108
+ key: "chargebacks",
1109
+ project: projectChargeback,
1110
+ query: {
1111
+ contributionId: args["contributionId"],
1112
+ minimumUpdatedDate: args["minimumUpdatedDate"],
1113
+ maximumUpdatedDate: args["maximumUpdatedDate"],
1114
+ sortDirection: args["sortDirection"],
1115
+ },
1116
+ }, ctx),
1117
+ },
1118
+ {
1119
+ name: "list_donors",
1120
+ description: "List donors with name, email, gift count, and recurring count. Cannot search by name or email; omits phone and address, which get_donor returns.",
1121
+ annotations: readOnly,
1122
+ inputSchema: namedInput({
1123
+ locationIds: ID_LIST("list_locations"),
1124
+ minimumUpdatedDate: DATE_PROPERTY("Updated on or after"),
1125
+ maximumUpdatedDate: DATE_PROPERTY("Updated on or before"),
1126
+ sortBy: {
1127
+ type: "string",
1128
+ enum: ["updatedAt", "createdAt", "lastName", "firstName", "email", "totalContributionsCount"],
1129
+ description: "Sort field. Defaults to updatedAt.",
1130
+ },
1131
+ sortDirection: { ...SORT_DIRECTION, description: "Defaults to DESC." },
1132
+ page: PAGE_PROPERTY,
1133
+ limit: LIMIT,
1134
+ raw: RAW_PROPERTY,
1135
+ }),
1136
+ outputSchema: listOutput("donors", DONOR_SCHEMA),
1137
+ handler: (args, ctx) => list({
1138
+ args,
1139
+ path: "/donors",
1140
+ key: "donors",
1141
+ project: (row) => projectDonor(row, false),
1142
+ query: {
1143
+ "locationIds[]": args["locationIds"],
1144
+ minimumUpdatedDate: args["minimumUpdatedDate"],
1145
+ maximumUpdatedDate: args["maximumUpdatedDate"],
1146
+ sortBy: args["sortBy"],
1147
+ sortDirection: args["sortDirection"],
1148
+ },
1149
+ }, ctx),
1150
+ },
1151
+ {
1152
+ name: "get_donor",
1153
+ description: "Get one donor's full profile: contact details, mailing address, locations, and giving counts. Their gifts are list_contributions rows.",
1154
+ annotations: readOnly,
1155
+ inputSchema: namedInput({ donorId: idProperty("list_donors or a contribution's donor.id") }, ["donorId"]),
1156
+ outputSchema: DONOR_SCHEMA,
1157
+ handler: async (args, ctx) => projectDonor(await one(`/donors/${segment(args["donorId"])}`, ctx), true),
1158
+ },
1159
+ {
1160
+ name: "list_subscriptions",
1161
+ description: "List recurring gifts with status, amount, frequency, next payment date, and campaign — for the nonprofit or, given donorId, one donor.",
1162
+ annotations: readOnly,
1163
+ inputSchema: namedInput({
1164
+ donorId: idProperty("list_donors; omit for every donor"),
1165
+ status: {
1166
+ type: "array",
1167
+ minItems: 1,
1168
+ uniqueItems: true,
1169
+ items: {
1170
+ type: "string",
1171
+ enum: ["active", "paused", "pending", "failed", "inactive", "recreated"],
1172
+ description: "Recurring-gift status.",
1173
+ },
1174
+ description: "Statuses; matches any.",
1175
+ },
1176
+ locationIds: ID_LIST("list_locations"),
1177
+ minimumUpdatedDate: DATE_PROPERTY("Updated on or after"),
1178
+ maximumUpdatedDate: DATE_PROPERTY("Updated on or before"),
1179
+ sortBy: {
1180
+ type: "string",
1181
+ enum: ["createdAt", "amount", "frequency", "nextContributionAt"],
1182
+ description: "Sort field. Defaults to createdAt.",
1183
+ },
1184
+ sortDirection: { ...SORT_DIRECTION, description: "Defaults to DESC." },
1185
+ page: PAGE_PROPERTY,
1186
+ limit: LIMIT,
1187
+ raw: RAW_PROPERTY,
1188
+ }),
1189
+ outputSchema: listOutput("subscriptions", SUBSCRIPTION_SCHEMA),
1190
+ handler: (args, ctx) => list({
1191
+ args,
1192
+ path: args["donorId"] === undefined
1193
+ ? "/subscriptions"
1194
+ : `/subscriptions/${segment(args["donorId"])}`,
1195
+ key: "subscriptions",
1196
+ project: projectSubscription,
1197
+ query: {
1198
+ "status[]": args["status"],
1199
+ "locationIds[]": args["locationIds"],
1200
+ minimumUpdatedDate: args["minimumUpdatedDate"],
1201
+ maximumUpdatedDate: args["maximumUpdatedDate"],
1202
+ sortBy: args["sortBy"],
1203
+ sortDirection: args["sortDirection"],
1204
+ },
1205
+ }, ctx),
1206
+ },
1207
+ {
1208
+ name: "list_payment_methods",
1209
+ description: "List one donor's saved payment methods (type, last 4, expiry) and their ids. Unpaginated. Never returns account or card numbers.",
1210
+ annotations: readOnly,
1211
+ inputSchema: namedInput({
1212
+ donorId: idProperty("list_donors"),
1213
+ showExpired: { type: "boolean", description: "Include expired methods. Defaults to false." },
1214
+ raw: RAW_PROPERTY,
1215
+ }, ["donorId"]),
1216
+ outputSchema: {
1217
+ type: "object",
1218
+ properties: { paymentMethods: { type: "array", items: PAYMENT_METHOD_SCHEMA } },
1219
+ required: ["paymentMethods"],
1220
+ },
1221
+ handler: async (args, ctx) => {
1222
+ const payload = await callOverflow(send, {
1223
+ method: "GET",
1224
+ path: `/payment-methods/${segment(args["donorId"])}`,
1225
+ query: { showExpired: args["showExpired"] },
1226
+ }, ctx);
1227
+ const rows = asArray(asRecord(payload)["data"]);
1228
+ return { paymentMethods: args["raw"] === true ? rows : rows.map(projectPaymentMethod) };
1229
+ },
1230
+ },
1231
+ {
1232
+ name: "list_campaigns",
1233
+ description: "List campaigns or subcampaigns with status, dates, and giving totals. Ids feed the campaignId filters and gift bodies.",
1234
+ annotations: readOnly,
1235
+ inputSchema: namedInput({
1236
+ isSubcampaign: { type: "boolean", description: "True lists subcampaigns instead. Defaults to false." },
1237
+ parentCampaignId: idProperty("list_campaigns; lists its subcampaigns"),
1238
+ search: { type: "string", minLength: 1, description: "Campaign-name search." },
1239
+ includeArchived: { type: "boolean", description: "Include archived campaigns." },
1240
+ excludeInactive: { type: "boolean", description: "Exclude campaigns that have ended." },
1241
+ sortBy: {
1242
+ type: "string",
1243
+ enum: ["startDate", "endDate", "name", "displayOrder", "totalContributionCount", "totalContributionValue", "uniqueDonorCount"],
1244
+ description: "Sort field. Defaults to startDate.",
1245
+ },
1246
+ sortDirection: { ...SORT_DIRECTION, description: "Defaults to DESC." },
1247
+ page: PAGE_PROPERTY,
1248
+ limit: LIMIT,
1249
+ raw: RAW_PROPERTY,
1250
+ }),
1251
+ outputSchema: listOutput("campaigns", CAMPAIGN_SCHEMA),
1252
+ handler: (args, ctx) => list({
1253
+ args,
1254
+ path: "/campaigns",
1255
+ key: "campaigns",
1256
+ project: projectCampaign,
1257
+ query: {
1258
+ isSubcampaign: args["isSubcampaign"] === undefined
1259
+ ? args["parentCampaignId"] === undefined ? undefined : "true"
1260
+ : String(args["isSubcampaign"]),
1261
+ parentCampaignId: args["parentCampaignId"],
1262
+ search: args["search"],
1263
+ includeArchived: args["includeArchived"],
1264
+ excludeInactive: args["excludeInactive"],
1265
+ sortBy: args["sortBy"],
1266
+ sortDirection: args["sortDirection"],
1267
+ },
1268
+ }, ctx),
1269
+ },
1270
+ {
1271
+ name: "list_locations",
1272
+ description: "List the nonprofit's locations (campuses) with names and ids for the locationIds filters. The default location is flagged.",
1273
+ annotations: readOnly,
1274
+ inputSchema: namedInput({
1275
+ includeArchived: { type: "boolean", description: "Include archived locations." },
1276
+ sortBy: { type: "string", enum: ["displayOrder", "name", "createdAt"], description: "Sort field. Defaults to displayOrder." },
1277
+ sortDirection: { ...SORT_DIRECTION, description: "Defaults to ASC." },
1278
+ page: PAGE_PROPERTY,
1279
+ limit: LIMIT,
1280
+ }),
1281
+ outputSchema: listOutput("locations", LOCATION_SCHEMA),
1282
+ handler: (args, ctx) => list({
1283
+ args,
1284
+ path: "/locations",
1285
+ key: "locations",
1286
+ project: projectLocation,
1287
+ query: {
1288
+ includeArchived: args["includeArchived"],
1289
+ sortBy: args["sortBy"],
1290
+ sortDirection: args["sortDirection"],
1291
+ },
1292
+ }, ctx),
1293
+ },
1294
+ {
1295
+ name: "list_tap_events",
1296
+ description: "List Tap-to-give taps with the device, group, and destination each hit. Taps are engagement, not gifts; giving is list_contributions.",
1297
+ annotations: readOnly,
1298
+ inputSchema: namedInput({
1299
+ startDate: DATE_PROPERTY("On or after"),
1300
+ endDate: DATE_PROPERTY("On or before"),
1301
+ groupIds: ID_LIST("a tap event's groupId"),
1302
+ destinationIds: ID_LIST("a tap event's destinationId"),
1303
+ deviceIds: ID_LIST("a tap event's deviceId"),
1304
+ sortDirection: { ...SORT_DIRECTION, description: "Time order. Defaults to DESC." },
1305
+ page: PAGE_PROPERTY,
1306
+ limit: LIMIT,
1307
+ raw: RAW_PROPERTY,
1308
+ }),
1309
+ outputSchema: listOutput("events", TAP_EVENT_SCHEMA),
1310
+ handler: (args, ctx) => list({
1311
+ args,
1312
+ path: "/tap/events",
1313
+ key: "events",
1314
+ project: projectTapEvent,
1315
+ query: {
1316
+ startDate: args["startDate"],
1317
+ endDate: args["endDate"],
1318
+ "groupIds[]": args["groupIds"],
1319
+ "destinationIds[]": args["destinationIds"],
1320
+ "deviceIds[]": args["deviceIds"],
1321
+ sortDirection: args["sortDirection"],
1322
+ },
1323
+ }, ctx),
1324
+ },
1325
+ {
1326
+ name: "list_webhooks",
1327
+ description: "List the nonprofit's webhook subscriptions with destination URL, enabled events, and status. Delivery history is list_webhook_event_logs.",
1328
+ annotations: readOnly,
1329
+ inputSchema: namedInput({
1330
+ status: { type: "string", enum: ["enabled", "disabled"], description: "Subscription status." },
1331
+ sortDirection: { ...SORT_DIRECTION, description: "Created-date order. Defaults to DESC." },
1332
+ page: PAGE_PROPERTY,
1333
+ limit: LIMIT,
1334
+ }),
1335
+ outputSchema: listOutput("webhooks", WEBHOOK_SCHEMA),
1336
+ handler: (args, ctx) => list({
1337
+ args,
1338
+ path: "/webhooks",
1339
+ key: "webhooks",
1340
+ project: projectWebhook,
1341
+ query: { status: args["status"], sortDirection: args["sortDirection"] },
1342
+ }, ctx),
1343
+ },
1344
+ {
1345
+ name: "list_webhook_event_logs",
1346
+ description: "List delivery attempts for one webhook with status, response code, and error. Omits event payloads unless raw. Pages by cursor, not page.",
1347
+ annotations: readOnly,
1348
+ inputSchema: namedInput({
1349
+ webhookId: idProperty("list_webhooks"),
1350
+ status: { type: "string", enum: ["delivered", "failed", "pending"], description: "Delivery status." },
1351
+ webhookEventName: {
1352
+ type: "string",
1353
+ pattern: "^(\\*|[a-z_]+\\.[a-z_]+)$",
1354
+ description: "Event name, e.g. contribution.approved or donor.updated.",
1355
+ },
1356
+ sortDirection: { ...SORT_DIRECTION, description: "Attempt order. Defaults to DESC." },
1357
+ cursor: {
1358
+ type: "string",
1359
+ minLength: 1,
1360
+ description: "This tool pages by cursor: pass nextCursor back unchanged.",
1361
+ },
1362
+ limit: {
1363
+ type: "integer",
1364
+ minimum: 1,
1365
+ maximum: MAX_EVENT_LOG_PAGE_SIZE,
1366
+ description: `Attempts per page, up to Overflow's cap of ${MAX_EVENT_LOG_PAGE_SIZE} for this endpoint. Defaults to ${DEFAULT_EVENT_LOG_PAGE_SIZE}.`,
1367
+ },
1368
+ raw: RAW_PROPERTY,
1369
+ }, ["webhookId"]),
1370
+ outputSchema: {
1371
+ type: "object",
1372
+ properties: {
1373
+ attempts: { type: "array", items: EVENT_LOG_SCHEMA },
1374
+ nextCursor: {
1375
+ type: "string",
1376
+ description: "Present only when more attempts exist; no page object.",
1377
+ },
1378
+ },
1379
+ required: ["attempts"],
1380
+ },
1381
+ handler: async (args, ctx) => {
1382
+ const payload = asRecord(await callOverflow(send, {
1383
+ method: "GET",
1384
+ path: `/webhooks/${segment(args["webhookId"])}/event-logs`,
1385
+ query: {
1386
+ status: args["status"],
1387
+ webhookEventName: args["webhookEventName"],
1388
+ sortDirection: args["sortDirection"],
1389
+ cursor: args["cursor"],
1390
+ limit: args["limit"] ?? DEFAULT_EVENT_LOG_PAGE_SIZE,
1391
+ },
1392
+ }, ctx));
1393
+ const rows = asArray(payload["data"]);
1394
+ const info = asRecord(payload["pageInfo"]);
1395
+ const next = info["nextCursor"];
1396
+ return compact({
1397
+ attempts: args["raw"] === true ? rows : rows.map(projectEventLog),
1398
+ nextCursor: info["hasNextPage"] === true && typeof next === "string" && next !== ""
1399
+ ? next
1400
+ : undefined,
1401
+ });
1402
+ },
1403
+ },
1404
+ ];
1405
+ }
1406
+ // --- Guide ---------------------------------------------------------------------
1407
+ function usageGuide(purpose, environment, instructions) {
1408
+ const accountInstructions = instructions?.trim();
1409
+ const environmentLine = environment === "production"
1410
+ ? "Overflow **production**: live nonprofit giving data, real donors, and real money."
1411
+ : "Overflow **staging**: Overflow's sandbox, isolated from production donors and money.";
1412
+ return `# Overflow usage
1413
+
1414
+ ${environmentLine}
1415
+
1416
+ Nonprofit purpose: ${purpose}
1417
+
1418
+ ## Money, units, and status
1419
+
1420
+ Overflow does not use one money unit, so read the unit per field:
1421
+
1422
+ - Fields named \`…InCents\` are cents: deposits and their line items and
1423
+ totals, refunds, chargebacks, and the authorize body.
1424
+ - Recurring-gift \`amount\` is dollars: Overflow's create and update bodies
1425
+ say so, and its subscription reads carry the same field.
1426
+ - Contribution \`amount\` states no unit in Overflow's schema. Check one known
1427
+ gift before reporting totals.
1428
+ - \`status\` is per asset type. A gift is final at \`PAID_OUT\` (cash),
1429
+ \`CONFIRMED\` (manual cash, crypto), \`CONTRIBUTION_RECEIVED\` or \`BILLED\`
1430
+ (stock), and \`CONTRIBUTION_RECEIVED\` (DAF). \`statusBucket\` filters by
1431
+ Overflow's coarser PENDING / CONFIRMED / FAILED / CANCELED.
1432
+ - There is no funds endpoint. A gift reports its campaign and subcampaign;
1433
+ \`fundId\` and \`subFundId\` appear only in the payment-authorize body.
1434
+ - Poll for changes with \`minimumUpdatedDate\`; \`minimumInitiatedDate\` is
1435
+ when the donor gave.
1436
+
1437
+ ## Reconcile a deposit
1438
+
1439
+ \`list_deposits\` → \`get_deposit_summary\` for totals → \`get_deposit\` for the
1440
+ line items, whose \`referenceId\` names the contribution, refund, or
1441
+ chargeback. Contributions also carry their \`depositId\`.
1442
+
1443
+ ## Donor data is sensitive
1444
+
1445
+ Giving history and donor contact details are personal data. Reduce inside
1446
+ \`execute_code\` — aggregate, count, or keep ids — and return names, emails,
1447
+ phones, or addresses only when the task needs them. Lists and contributions
1448
+ already omit contact details; \`get_donor\` is the deliberate lookup.
1449
+
1450
+ ## Writes go through \`overflow_api_mutate\`
1451
+
1452
+ Every Overflow write moves money or changes a donor record, so none has a
1453
+ named tool. Each is one approval-gated \`overflow_api_mutate\` call with
1454
+ Overflow's documented body:
1455
+
1456
+ - \`POST /contributions\` charges a saved payment method. Its \`amount\`
1457
+ states no unit; confirm it against a known gift of the same donor before
1458
+ charging. \`POST /payments/authorize\` takes \`amountInCents\` and
1459
+ authorizes a new method (\`0\` only saves it).
1460
+ - \`POST /contributions/{id}/initiate-refund\` (under $1,000 only).
1461
+ - \`POST\` / \`PATCH\` / \`DELETE /subscriptions/{donorId}[/{subscriptionId}]\`
1462
+ creates, changes, or cancels a recurring gift. Its \`amount\` is dollars,
1463
+ not cents: a $50.00 monthly gift is \`amount: 50\`. Cancel takes a
1464
+ \`cancellationReason\` body.
1465
+ - \`POST /donors\`, \`PATCH /donors/{id}\`.
1466
+
1467
+ Resolve every id first — donors, payment methods (\`list_payment_methods\`),
1468
+ campaigns, locations — and never guess one. A write that fails with HTTP 5xx
1469
+ may still have happened: read before retrying.
1470
+
1471
+ ## Hatches, pages, and limits
1472
+
1473
+ - \`overflow_api_get\` reaches the reads without a named tool: single refunds,
1474
+ chargebacks, campaigns, locations, webhooks, and recurring gifts
1475
+ (\`/subscriptions/{donorId}/{id}\`), and Tap devices, groups, and
1476
+ destinations (\`/tap/devices\`, \`/tap/groups\`, \`/tap/destinations\`). Paths
1477
+ are below \`/api/v3\`; array filters repeat a \`name[]\` query pair.
1478
+ - Lists page by number: pass \`page\` while \`page.hasMore\` is true.
1479
+ \`list_webhook_event_logs\` pages by cursor instead.
1480
+ - Overflow allows 120 requests per minute per API client. This connection
1481
+ admits the same per runtime, four at a time, which approximates rather than
1482
+ enforces it; a \`rate_limited\` failure carries the wait.
1483
+ ${accountInstructions
1484
+ ? `\n## Nonprofit instructions\n\n${accountInstructions}\n`
1485
+ : ""}`;
1486
+ }
1487
+ // --- Construction --------------------------------------------------------------
1488
+ /**
1489
+ * A maintained Overflow connection.
1490
+ *
1491
+ * `environment` is required with no default, and the argument runs the other
1492
+ * way from a provider whose environment rides the credential. Overflow
1493
+ * publishes two base URLs and issues separate credentials for each, so a
1494
+ * mismatch fails loudly with a 403 rather than reaching the wrong data — but
1495
+ * that is exactly why a default buys nothing. Defaulting to staging, the
1496
+ * "safe" side, sends every real deployment to a sandbox where its production
1497
+ * key is refused with a generic "Forbidden resource" and no hint that the base
1498
+ * URL is the problem. Defaulting to production makes a test deployment that
1499
+ * forgot the option one pasted key away from charging real donors through the
1500
+ * mutate hatch. The environment is a fact the operator knows and connecta
1501
+ * cannot infer — Overflow's keys carry no recognizable environment marker — so
1502
+ * the operator states it, and the title and the guide's first line repeat it.
1503
+ */
1504
+ export function overflow(id, options) {
1505
+ const purpose = options.purpose?.trim();
1506
+ if (!purpose) {
1507
+ throw new Error("overflow() requires a non-empty nonprofit purpose.");
1508
+ }
1509
+ const environment = options.environment;
1510
+ if (environment !== "production" && environment !== "staging") {
1511
+ throw new Error('overflow() requires environment: "production" or "staging". Overflow issues separate credentials for each, and connecta will not guess which one a deployment means.');
1512
+ }
1513
+ const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE;
1514
+ if (!Number.isInteger(defaultPageSize) ||
1515
+ defaultPageSize < 1 ||
1516
+ defaultPageSize > MAX_PAGE_SIZE) {
1517
+ throw new Error(`overflow() defaultPageSize must be a whole number between 1 and ${MAX_PAGE_SIZE}.`);
1518
+ }
1519
+ const send = overflowTransport(options.baseUrl?.trim() || OVERFLOW_API_BASE_URLS[environment]);
1520
+ const label = environment === "production" ? "production" : "staging";
1521
+ return api(id, {
1522
+ ...defined({ authScope: options.authScope, maxResultBytes: options.maxResultBytes }),
1523
+ title: options.title ?? `Overflow (${label})`,
1524
+ description: `Overflow nonprofit giving, ${label}: ${purpose}`,
1525
+ credential: API_KEY_CREDENTIAL,
1526
+ async testCredentials(values, ctx) {
1527
+ // Overflow has no identity endpoint, and /health answers without a
1528
+ // credential, so it proves nothing. One location is the cheapest
1529
+ // authenticated read: every nonprofit has at least its default one, and
1530
+ // its name is the closest thing to "which organization is this?" the
1531
+ // API will say.
1532
+ try {
1533
+ const payload = asRecord(await callOverflow(send, { method: "GET", path: "/locations", query: { limit: 1, sortBy: "displayOrder" } }, {
1534
+ ...ctx,
1535
+ credential: {
1536
+ get: async (field) => (field ? values[field] ?? null : null),
1537
+ getAll: async () => values,
1538
+ },
1539
+ }));
1540
+ const first = asRecord(asArray(payload["data"])[0]);
1541
+ const total = payload["totalCount"];
1542
+ const where = typeof first["name"] === "string"
1543
+ ? ` The nonprofit's first location is "${first["name"]}"${typeof total === "number" ? ` of ${total}` : ""}.`
1544
+ : "";
1545
+ return {
1546
+ ok: true,
1547
+ message: `Overflow ${label} accepted the client id and API key.${where}`,
1548
+ };
1549
+ }
1550
+ catch (error) {
1551
+ return {
1552
+ ok: false,
1553
+ message: error instanceof ConnectorCallError
1554
+ ? error.message
1555
+ : `Overflow ${label} rejected the client id and API key.`,
1556
+ };
1557
+ }
1558
+ },
1559
+ callAdmission: options.callAdmission ?? OVERFLOW_ADMISSION,
1560
+ usageGuide: {
1561
+ content: usageGuide(purpose, environment, options.instructions),
1562
+ summary: `Overflow ${label}: money units per field, final statuses, deposits, donor-PII care, and writes via mutate.`,
1563
+ // Required because correct use depends on facts no schema carries:
1564
+ // which statuses are final per asset type, which money unit each field
1565
+ // and write body uses (cents, dollars, or unstated), and
1566
+ // that every write is a mutate-hatch call whose body comes from
1567
+ // Overflow's reference.
1568
+ required: true,
1569
+ },
1570
+ tools: tools(send, defaultPageSize),
1571
+ });
1572
+ }