@zackbart/connecta 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +961 -5
- package/README.md +62 -39
- package/bin/connecta.mjs +110 -139
- package/bin/meta-tool-names.d.mts +2 -0
- package/bin/meta-tool-names.mjs +9 -0
- package/bin/version.mjs +3 -0
- package/dist/access-tokens.js +53 -63
- package/dist/activity-request.js +8 -0
- package/dist/activity.d.ts +76 -42
- package/dist/activity.js +110 -42
- package/dist/auth/clerk-sdk/client.d.ts +65 -0
- package/dist/auth/clerk-sdk/client.js +5 -0
- package/dist/auth/clerk-sdk/generated.js +1845 -0
- package/dist/auth/clerk-transport.js +19 -0
- package/dist/auth/clerk.d.ts +17 -7
- package/dist/auth/clerk.js +189 -107
- package/dist/auth/cloudflare-access.js +25 -26
- package/dist/auth/downstream-client-metadata.d.ts +29 -0
- package/dist/auth/downstream-client-metadata.js +133 -0
- package/dist/auth/downstream-oauth.js +1632 -1509
- package/dist/auth/downstream-revocation.js +106 -0
- package/dist/auth/oauth-token-response.js +231 -0
- package/dist/auth/oauth-v2-migration.js +116 -0
- package/dist/auth/static-oauth.js +526 -0
- package/dist/auth-elicitation.js +283 -0
- package/dist/branding.d.ts +8 -16
- package/dist/branding.js +26 -59
- package/dist/byte-read-response.js +39 -0
- package/dist/call-admission.js +12 -16
- package/dist/catalog-cache.js +739 -0
- package/dist/catalog-drift.d.ts +50 -15
- package/dist/catalog-drift.js +365 -128
- package/dist/catalog-fingerprint.js +2 -4
- package/dist/catalog-freshness.js +13 -0
- package/dist/catalog-limits.js +2 -2
- package/dist/catalog-service.js +368 -224
- package/dist/catalog.js +43 -84
- package/dist/concurrency.js +3 -4
- package/dist/config-defaults.d.ts +39 -0
- package/dist/config-defaults.js +46 -0
- package/dist/config-schema.d.ts +181 -0
- package/dist/config-schema.js +435 -0
- package/dist/config-value-sources.js +102 -0
- package/dist/config.d.ts +631 -0
- package/dist/config.js +503 -0
- package/dist/connector-access.d.ts +1 -0
- package/dist/connector-access.js +13 -15
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +15 -6
- package/dist/connector-scope.js +1 -1
- package/dist/connectors/api-connector.d.ts +220 -0
- package/dist/connectors/api-connector.js +214 -0
- package/dist/connectors/api.d.ts +8 -71
- package/dist/connectors/api.js +13 -99
- package/dist/connectors/guarded-fetch.d.ts +144 -0
- package/dist/connectors/guarded-fetch.js +108 -25
- package/dist/connectors/negotiation-cache.js +46 -0
- package/dist/connectors/option-shapes.js +27 -0
- package/dist/connectors/remote-mcp.d.ts +30 -4
- package/dist/connectors/remote-mcp.js +1780 -351
- package/dist/connectors/resource-uri.js +335 -0
- package/dist/credential-contract.d.ts +10 -0
- package/dist/credential-rules.js +1 -3
- package/dist/credential-url.js +108 -0
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +27 -18
- package/dist/d1.d.ts +130 -0
- package/dist/d1.js +290 -0
- package/dist/describe-config.d.ts +165 -0
- package/dist/describe-config.js +315 -0
- package/dist/described.js +106 -0
- package/dist/downstream-input-context.js +119 -0
- package/dist/downstream-input.js +323 -0
- package/dist/errors.d.ts +97 -20
- package/dist/errors.js +158 -80
- package/dist/execute.js +453 -305
- package/dist/executor-admission.d.ts +1 -0
- package/dist/executor-admission.js +11 -15
- package/dist/executor-contract.d.ts +20 -0
- package/dist/executor-contract.js +48 -0
- package/dist/executor-result.js +24 -9
- package/dist/executors/quickjs-child.js +3 -4
- package/dist/executors/quickjs-runtime.js +147 -59
- package/dist/executors/quickjs.js +29 -35
- package/dist/favicon.js +1 -2
- package/dist/fix-prompt.js +1 -3
- package/dist/guest-runtime.js +188 -0
- package/dist/guest-types.d.ts +176 -0
- package/dist/guest-types.js +1 -0
- package/dist/html-security.js +24 -0
- package/dist/identity.js +1 -3
- package/dist/inbound-credential.js +52 -0
- package/dist/index.d.ts +25 -257
- package/dist/index.js +65 -478
- package/dist/invocation-auth.js +69 -0
- package/dist/invocation.js +230 -73
- package/dist/json-schema.d.ts +1 -1
- package/dist/mcp-client-context.js +35 -0
- package/dist/mcp-result.js +11 -7
- package/dist/meta-output.js +76 -0
- package/dist/meta-tool-names.d.ts +2 -0
- package/dist/meta-tool-names.js +3 -0
- package/dist/meta-tools.js +285 -209
- package/dist/module-contracts.d.ts +8 -23
- package/dist/node.d.ts +0 -2
- package/dist/node.js +2 -2
- package/dist/oauth-callback-outcome.js +1 -3
- package/dist/oauth-handoff.js +117 -0
- package/dist/oauth-partition.js +17 -0
- package/dist/oauth-sealing.js +20 -0
- package/dist/operator-record.d.ts +137 -0
- package/dist/operator-record.js +420 -0
- package/dist/operator-ui/assets.js +31 -0
- package/dist/operator-ui/contract.d.ts +72 -0
- package/dist/operator-ui/contract.js +1 -0
- package/dist/operator-ui/fix-prompts.d.ts +18 -0
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/model.d.ts +12 -87
- package/dist/operator-ui/model.js +2 -9
- package/dist/operator-ui/setup-commands.js +1 -3
- package/dist/operator-ui/view.d.ts +323 -0
- package/dist/operator-ui/view.js +62 -189
- package/dist/page-styles.js +2 -3
- package/dist/program-source.js +14 -2
- package/dist/{exempt-writes.js → program-writes.js} +55 -18
- package/dist/provider.d.ts +142 -0
- package/dist/provider.js +187 -0
- package/dist/providers/_shared/google/result-size.js +57 -0
- package/dist/providers/_shared/google/service-account.d.ts +58 -0
- package/dist/providers/_shared/google/service-account.js +500 -0
- package/dist/providers/_shared/google/workspace.d.ts +276 -0
- package/dist/providers/_shared/google/workspace.js +518 -0
- package/dist/providers/basecamp/index.d.ts +43 -0
- package/dist/providers/basecamp/index.js +997 -0
- package/dist/providers/basecamp/skill.generated.js +9 -0
- package/dist/providers/breeze/index.d.ts +34 -0
- package/dist/providers/breeze/index.js +1446 -0
- package/dist/providers/breeze/skill.generated.js +9 -0
- package/dist/providers/ccb/index.d.ts +103 -0
- package/dist/providers/ccb/index.js +1725 -0
- package/dist/providers/ccb/skill.generated.js +9 -0
- package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
- package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
- package/dist/providers/cloudflare/skill.generated.js +10 -0
- package/dist/providers/docs/index.d.ts +54 -0
- package/dist/providers/docs/index.js +1237 -0
- package/dist/providers/docs/skill.generated.js +9 -0
- package/dist/providers/drive/index.d.ts +53 -0
- package/dist/providers/drive/index.js +1523 -0
- package/dist/providers/drive/skill.generated.js +9 -0
- package/dist/providers/forms/index.d.ts +54 -0
- package/dist/providers/forms/index.js +1217 -0
- package/dist/providers/forms/skill.generated.js +11 -0
- package/dist/providers/github/auth.d.ts +32 -0
- package/dist/providers/github/auth.js +361 -0
- package/dist/providers/github/hosted.js +218 -0
- package/dist/providers/github/index.d.ts +13 -0
- package/dist/providers/github/index.js +140 -0
- package/dist/providers/github/key.js +100 -0
- package/dist/providers/github/rest.js +321 -0
- package/dist/providers/github/scope.d.ts +29 -0
- package/dist/providers/github/scope.js +124 -0
- package/dist/providers/github/skill.generated.js +8 -0
- package/dist/providers/gmail/index.d.ts +51 -0
- package/dist/providers/gmail/index.js +1436 -0
- package/dist/providers/gmail/skill.generated.js +9 -0
- package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
- package/dist/providers/linear/index.js +352 -0
- package/dist/providers/linear/skill.generated.js +8 -0
- package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
- package/dist/providers/mixpanel/index.js +416 -0
- package/dist/providers/mixpanel/skill.generated.js +9 -0
- package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
- package/dist/providers/{notion.js → notion/index.js} +226 -239
- package/dist/providers/notion/skill.generated.js +10 -0
- package/dist/providers/overflow/index.d.ts +60 -0
- package/dist/providers/overflow/index.js +1535 -0
- package/dist/providers/overflow/skill.generated.js +8 -0
- package/dist/providers/planning-center/index.d.ts +59 -0
- package/dist/providers/planning-center/index.js +2199 -0
- package/dist/providers/planning-center/skill.generated.js +10 -0
- package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
- package/dist/providers/revenuecat/index.js +557 -0
- package/dist/providers/revenuecat/skill.generated.js +11 -0
- package/dist/providers/sheets/index.d.ts +54 -0
- package/dist/providers/sheets/index.js +1237 -0
- package/dist/providers/sheets/skill.generated.js +10 -0
- package/dist/providers/slides/index.d.ts +53 -0
- package/dist/providers/slides/index.js +2645 -0
- package/dist/providers/slides/skill.generated.js +9 -0
- package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
- package/dist/providers/stripe/index.js +266 -0
- package/dist/providers/stripe/skill.generated.js +11 -0
- package/dist/providers/tithely/index.d.ts +49 -0
- package/dist/providers/tithely/index.js +1003 -0
- package/dist/providers/tithely/skill.generated.js +8 -0
- package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
- package/dist/providers/vercel/index.js +1038 -0
- package/dist/providers/vercel/mcp-ownership.js +212 -0
- package/dist/providers/vercel/skill.generated.js +11 -0
- package/dist/registry.d.ts +108 -171
- package/dist/registry.js +513 -925
- package/dist/request-state.js +21 -0
- package/dist/resource-read.js +100 -0
- package/dist/result-shapes.js +17 -49
- package/dist/retired-toolkits.js +1 -1
- package/dist/routes/access-tokens.js +3 -6
- package/dist/routes/activity-context.js +21 -0
- package/dist/routes/activity-disclosure.js +40 -0
- package/dist/routes/activity.js +59 -21
- package/dist/routes/actor-labels.js +3 -50
- package/dist/routes/connect.js +221 -0
- package/dist/routes/credentials.js +15 -27
- package/dist/routes/mcp.js +356 -130
- package/dist/routes/oauth-client-metadata.js +15 -0
- package/dist/routes/oauth-management.js +45 -107
- package/dist/routes/oauth.js +136 -57
- package/dist/routes/operator.js +3 -3
- package/dist/routes/shared.d.ts +25 -64
- package/dist/routes/shared.js +160 -80
- package/dist/routes/skills.js +30 -0
- package/dist/routes/ui-config.js +225 -0
- package/dist/routes/ui-data.js +40 -35
- package/dist/routes/ui.js +66 -56
- package/dist/runtime/admission.js +4 -2
- package/dist/runtime/call-admission.js +3 -1
- package/dist/runtime/connector-scope.js +1 -4
- package/dist/runtime/run.js +10 -39
- package/dist/runtime/services.js +12 -117
- package/dist/runtime/shared-read.js +1 -3
- package/dist/runtime/storage.js +8 -1
- package/dist/sent-secrets.d.ts +52 -0
- package/dist/sent-secrets.js +474 -0
- package/dist/server.js +28 -71
- package/dist/skills.js +406 -174
- package/dist/sqlite.d.ts +48 -0
- package/dist/sqlite.js +166 -0
- package/dist/storage/keys.js +428 -0
- package/dist/storage/memory.js +7 -0
- package/dist/storage/sql.d.ts +66 -0
- package/dist/storage/sql.js +534 -0
- package/dist/timeout.js +4 -2
- package/dist/tool-name.js +17 -0
- package/dist/tool-safety.d.ts +9 -35
- package/dist/tool-safety.js +20 -40
- package/dist/types.d.ts +320 -54
- package/dist/types.js +0 -1
- package/dist/ui.d.ts +7 -19
- package/dist/ui.js +76 -90
- package/dist/url-safety.js +3 -6
- package/dist/usage-guide.js +137 -0
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +309 -47
- package/dist/version.d.ts +1 -7
- package/dist/version.js +3 -7
- package/dist/worker.d.ts +11 -0
- package/dist/worker.js +434 -0
- package/documentation/architecture.md +665 -230
- package/documentation/auth.md +962 -458
- package/documentation/code-mode.md +501 -312
- package/documentation/meta-tools.md +449 -200
- package/documentation/operator-tests.md +93 -0
- package/documentation/provider-migration-0.29.md +60 -0
- package/examples/worker/AGENTS.md +34 -12
- package/examples/worker/README.md +286 -255
- package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
- package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
- package/examples/worker/src/connecta.config.ts +151 -0
- package/examples/worker/src/index.ts +14 -197
- package/examples/worker/wrangler.jsonc +15 -35
- package/package.json +125 -35
- package/templates/node/.dockerignore +1 -2
- package/templates/node/.env.example +20 -21
- package/templates/node/AGENTS.md +21 -17
- package/templates/node/Dockerfile +3 -5
- package/templates/node/README.md +133 -58
- package/templates/node/docker-compose.yml +11 -12
- package/templates/node/package.json +4 -2
- package/templates/node/src/connecta.config.ts +119 -0
- package/templates/node/src/index.ts +9 -154
- package/templates/node/src/provision-token.ts +17 -0
- package/AGENTS.md +0 -170
- package/dist/artifacts/connector.d.ts +0 -58
- package/dist/artifacts/connector.js +0 -730
- package/dist/artifacts/document.js +0 -86
- package/dist/artifacts/guide.js +0 -140
- package/dist/artifacts/html-scan.js +0 -329
- package/dist/artifacts/json.js +0 -78
- package/dist/artifacts/kv-store.d.ts +0 -20
- package/dist/artifacts/kv-store.js +0 -140
- package/dist/artifacts/markdown.js +0 -478
- package/dist/artifacts/module.d.ts +0 -37
- package/dist/artifacts/module.js +0 -66
- package/dist/artifacts/operations.d.ts +0 -197
- package/dist/artifacts/operations.js +0 -1002
- package/dist/artifacts/refresh.d.ts +0 -55
- package/dist/artifacts/refresh.js +0 -236
- package/dist/artifacts/routes.js +0 -206
- package/dist/artifacts/types.d.ts +0 -214
- package/dist/artifacts/types.js +0 -22
- package/dist/artifacts/validate.d.ts +0 -62
- package/dist/artifacts/validate.js +0 -651
- package/dist/artifacts.d.ts +0 -6
- package/dist/artifacts.js +0 -9
- package/dist/auth/bearer.d.ts +0 -11
- package/dist/auth/bearer.js +0 -50
- package/dist/providers/linear.js +0 -217
- package/dist/providers/mixpanel.js +0 -247
- package/dist/providers/revenuecat.js +0 -317
- package/dist/providers/stripe.js +0 -253
- package/dist/providers/vercel.js +0 -1253
- package/dist/storage/file.d.ts +0 -15
- package/dist/storage/file.js +0 -455
- package/ethos.md +0 -93
- package/examples/worker/src/cloudflare-kv.ts +0 -43
- package/examples/worker/src/d1-activity-row.ts +0 -155
- package/examples/worker/src/d1-activity.ts +0 -147
- package/examples/worker/src/d1-storage.ts +0 -138
- package/examples/worker/src/r2-artifact-blobs.ts +0 -29
- package/templates/node/src/file-activity.ts +0 -182
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
import { skill } from "./skill.generated.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tithe.ly's giving API (v1), hand-written against the per-operation reference
|
|
4
|
+
* at https://docs.tithe.ly/reference/introduction. No SDK exists to depend on,
|
|
5
|
+
* and none would be welcome: this is a payments API, and every tool below was
|
|
6
|
+
* chosen and written, not generated (decision record 0001: hand-authored tools).
|
|
7
|
+
*
|
|
8
|
+
* Facts the code leans on, each from the reference unless marked otherwise:
|
|
9
|
+
*
|
|
10
|
+
* - **Access is gated.** Keys are issued on request: email support@tithe.ly
|
|
11
|
+
* with the church or organization and what you are building, and Tithe.ly
|
|
12
|
+
* sends a link to generate a public (`pub_…`) and private (`pri_…`) key.
|
|
13
|
+
* Live and test are separate accounts with separate key pairs, and a
|
|
14
|
+
* deployment cannot use one environment's keys against the other.
|
|
15
|
+
* - **Two published endpoints.** `https://tithe.ly/api/v1/` (live) and
|
|
16
|
+
* `https://tithelydev.com/api/v1/` (test). See `environment` below for why
|
|
17
|
+
* the choice is required rather than defaulted.
|
|
18
|
+
* - **HTTP Basic auth**, public key as user and private key as password.
|
|
19
|
+
* - **Envelopes vary by operation.** Lists answer `{status, type, data: []}`;
|
|
20
|
+
* single reads and writes answer `{status, <kind>_id, type, object}`; the
|
|
21
|
+
* reference's charge examples show a bare object. Amounts are integer cents
|
|
22
|
+
* serialized as strings (sometimes numbers), and dates are Unix-second
|
|
23
|
+
* strings — `deposit_date` may be the literal `"pending"`.
|
|
24
|
+
* - **Refusals arrive as HTTP 200** `{status: "fail", reason}` with no code.
|
|
25
|
+
* The reference documents no error shape at all; this one comes from the
|
|
26
|
+
* recorded fixtures of the third-party Omnipay driver
|
|
27
|
+
* (github.com/elvanto/omnipay-tithely), which also confirms the charge
|
|
28
|
+
* envelope and form-encoded request bodies. Treat it as well-supported
|
|
29
|
+
* observation, not contract.
|
|
30
|
+
* - **Writes are form-encoded.** Each operation snippet declares an
|
|
31
|
+
* `application/json` body — ReadMe's default — but every curl sample sends
|
|
32
|
+
* `-d field=value`, and so does the Omnipay driver. Form encoding is what the
|
|
33
|
+
* documented examples actually send, so the mutate hatch frames that.
|
|
34
|
+
* - **Cursor pagination** by id: `starting_after` / `ending_before` with
|
|
35
|
+
* `limit` (default 10; the charges page documents a maximum of 100) and
|
|
36
|
+
* `order_by` DESC|ASC. No `has_more` is returned.
|
|
37
|
+
* - **No published rate limit**, so no default `callAdmission` (P12).
|
|
38
|
+
* - **No deposits or settlements resource.** A charge carries `amount`,
|
|
39
|
+
* `net_amount`, `fees`, and `deposit_date`; that is the whole payout story.
|
|
40
|
+
* - **Not targeted:** an undocumented v2 (`https://tithe.ly/api/v2`, with
|
|
41
|
+
* transactions, funds, organizations, and mail per a third-party catalogue)
|
|
42
|
+
* exists but is not public. The guarded transport confines every path below
|
|
43
|
+
* `/api/v1`, so the hatches cannot reach it either.
|
|
44
|
+
*
|
|
45
|
+
* Drift: there is no combined specification, but each reference page served
|
|
46
|
+
* as `.md` embeds a one-operation OpenAPI 3.1 snippet. `scripts/drift-check.mjs`
|
|
47
|
+
* assembles those snippets for the pages listed in
|
|
48
|
+
* `src/providers/tithely/drift.json` and digests each operation like any
|
|
49
|
+
* other provider's, so `npm run providers:check -- --provider tithely` runs
|
|
50
|
+
* credential-free.
|
|
51
|
+
*/
|
|
52
|
+
import { apiConnector as api, defined } from "../../connectors/api-connector.js";
|
|
53
|
+
import { guardedFetch, retryAfterMs, } from "../../connectors/guarded-fetch.js";
|
|
54
|
+
import { ConnectorCallError } from "../../errors.js";
|
|
55
|
+
import { keys, optionsOf } from "../../config-schema.js";
|
|
56
|
+
import { PROVIDER_COMMON } from "../../connectors/option-shapes.js";
|
|
57
|
+
import { asProviderFactory } from "../../provider.js";
|
|
58
|
+
/** Tithe.ly's two published v1 endpoints, one per environment. */
|
|
59
|
+
export const TITHELY_API_BASE_URLS = {
|
|
60
|
+
live: "https://tithe.ly/api/v1",
|
|
61
|
+
test: "https://tithelydev.com/api/v1",
|
|
62
|
+
};
|
|
63
|
+
/** Tithe.ly's default page size is 10 and its documented maximum 100. */
|
|
64
|
+
const PROVIDER_MAX_PAGE_SIZE = 100;
|
|
65
|
+
/**
|
|
66
|
+
* One below the provider's maximum, because each list asks for one extra row
|
|
67
|
+
* to learn whether another page exists — Tithe.ly returns no `has_more`, and a
|
|
68
|
+
* full page is not proof of a next one.
|
|
69
|
+
*/
|
|
70
|
+
const MAX_PAGE_SIZE = PROVIDER_MAX_PAGE_SIZE - 1;
|
|
71
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
72
|
+
/** A full list page of fully embedded organizations stays far below this. */
|
|
73
|
+
const TITHELY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
74
|
+
function asRecord(value) {
|
|
75
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
76
|
+
}
|
|
77
|
+
function asArray(value) {
|
|
78
|
+
return Array.isArray(value) ? value : [];
|
|
79
|
+
}
|
|
80
|
+
function compact(value) {
|
|
81
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
82
|
+
}
|
|
83
|
+
function text(value) {
|
|
84
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
85
|
+
}
|
|
86
|
+
// --- Credentials --------------------------------------------------------------
|
|
87
|
+
const KEY_PAIR_CREDENTIAL = {
|
|
88
|
+
label: "Tithe.ly API keys",
|
|
89
|
+
description: "The public and private key pair for this connection's environment. API access is by request: email support@tithe.ly to have keys issued. Test keys work only against tithelydev.com and live keys only against tithe.ly.",
|
|
90
|
+
fields: [
|
|
91
|
+
{
|
|
92
|
+
name: "publicKey",
|
|
93
|
+
label: "Public key",
|
|
94
|
+
description: "Starts with pub_. Sent as the HTTP Basic user name.",
|
|
95
|
+
placeholder: "pub_…",
|
|
96
|
+
inputType: "text",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "privateKey",
|
|
100
|
+
label: "Private key",
|
|
101
|
+
description: "Starts with pri_. Sent as the HTTP Basic password; never expose it in a browser.",
|
|
102
|
+
placeholder: "pri_…",
|
|
103
|
+
inputType: "password",
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Refuse a pair that cannot be right before it costs a request: a missing
|
|
109
|
+
* half, keys swapped between the fields, or a header-unsafe character. Only
|
|
110
|
+
* the obvious mismatches — a key without either prefix is passed through,
|
|
111
|
+
* because Tithe.ly has never promised the prefix and a stricter check would
|
|
112
|
+
* lock out a key format it later introduces.
|
|
113
|
+
*/
|
|
114
|
+
function keyPairProblem(values) {
|
|
115
|
+
const publicKey = values?.["publicKey"]?.trim() ?? "";
|
|
116
|
+
const privateKey = values?.["privateKey"]?.trim() ?? "";
|
|
117
|
+
if (!publicKey || !privateKey) {
|
|
118
|
+
return "Both the Tithe.ly public key and private key are required.";
|
|
119
|
+
}
|
|
120
|
+
if (publicKey.startsWith("pri_") && privateKey.startsWith("pub_")) {
|
|
121
|
+
return "The Tithe.ly keys are swapped: the pub_ key belongs in the public-key field and the pri_ key in the private-key field.";
|
|
122
|
+
}
|
|
123
|
+
if (publicKey.startsWith("pri_")) {
|
|
124
|
+
return "The public-key field holds a private (pri_) key. Put the pub_ key there.";
|
|
125
|
+
}
|
|
126
|
+
if (privateKey.startsWith("pub_")) {
|
|
127
|
+
return "The private-key field holds a public (pub_) key. Put the pri_ key there.";
|
|
128
|
+
}
|
|
129
|
+
if (!/^[\x21-\x7e]+$/.test(publicKey) || !/^[\x21-\x7e]+$/.test(privateKey)) {
|
|
130
|
+
return "A Tithe.ly key contains whitespace or a non-ASCII character; paste the key exactly as issued.";
|
|
131
|
+
}
|
|
132
|
+
return { publicKey, privateKey };
|
|
133
|
+
}
|
|
134
|
+
const operatorKeyPair = async (ctx) => {
|
|
135
|
+
const pair = keyPairProblem((await ctx.credential?.getAll()) ?? null);
|
|
136
|
+
if (typeof pair === "string") {
|
|
137
|
+
throw new ConnectorCallError("auth_required", `${pair} Call authorize_connector for recovery options; an operator adds the keys on this connection in the operator UI.`);
|
|
138
|
+
}
|
|
139
|
+
return { Authorization: `Basic ${btoa(`${pair.publicKey}:${pair.privateKey}`)}` };
|
|
140
|
+
};
|
|
141
|
+
// --- Transport and failures -----------------------------------------------------
|
|
142
|
+
function detailFor(payload, status) {
|
|
143
|
+
const root = asRecord(payload);
|
|
144
|
+
const error = root["error"];
|
|
145
|
+
const message = text(root["reason"]) ?? text(root["message"]) ?? text(error) ?? text(asRecord(error)["message"]);
|
|
146
|
+
return message ? `Tithe.ly: ${message.trim()}` : `Tithe.ly returned HTTP ${status}.`;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Map a failure by the caller's next move (H11). Tithe.ly documents no error
|
|
150
|
+
* statuses, so every branch states what it cannot know rather than choosing
|
|
151
|
+
* the convenient reading.
|
|
152
|
+
*/
|
|
153
|
+
function tithelyFailure(status, headers, payload) {
|
|
154
|
+
const detail = detailFor(payload, status);
|
|
155
|
+
if (status === 429) {
|
|
156
|
+
const wait = retryAfterMs(headers);
|
|
157
|
+
return new ConnectorCallError("rate_limited", `${detail} Tithe.ly publishes no rate limit; wait before retrying, and add a callAdmission budget if this recurs.`, wait === undefined ? {} : { retryAfterMs: wait });
|
|
158
|
+
}
|
|
159
|
+
if (status === 401) {
|
|
160
|
+
return new ConnectorCallError("auth_required", `${detail} Tithe.ly rejected the key pair. It may be revoked, mistyped, or issued for the other environment — test keys work only on tithelydev.com, live keys only on tithe.ly. An operator must fix the keys on this connection.`);
|
|
161
|
+
}
|
|
162
|
+
if (status === 403) {
|
|
163
|
+
return new ConnectorCallError("provider_permission_denied", `${detail} Tithe.ly refused the request: the key pair may not reach this resource, or API access may not be enabled for this environment. Tithe.ly does not say which. Ask the account administrator to grant API access to this resource and environment.`, { retryable: false });
|
|
164
|
+
}
|
|
165
|
+
if (status === 404) {
|
|
166
|
+
// Not `not_found`: nothing documents whether an id outside this key's
|
|
167
|
+
// reach answers 404 too, so absence cannot be told from a permission gap.
|
|
168
|
+
return new ConnectorCallError("connector_call_failed", `${detail} The path does not exist, or the id is unknown or outside this key pair's reach; Tithe.ly does not distinguish them. Re-read the id from its list tool.`, { retryable: false });
|
|
169
|
+
}
|
|
170
|
+
if (status === 400 || status === 422) {
|
|
171
|
+
return new ConnectorCallError("invalid_args", detail);
|
|
172
|
+
}
|
|
173
|
+
if (status >= 500) {
|
|
174
|
+
const wait = retryAfterMs(headers);
|
|
175
|
+
return new ConnectorCallError("unavailable", `${detail} Tithe.ly is failing upstream.`, wait === undefined ? {} : { retryAfterMs: wait });
|
|
176
|
+
}
|
|
177
|
+
return new ConnectorCallError("connector_call_failed", detail, {
|
|
178
|
+
retryable: false,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Tithe.ly's in-band refusal: HTTP 200 with a `status` other than `success`.
|
|
183
|
+
* Its v1 API reports a declined payment, an unknown id, and a rejected
|
|
184
|
+
* argument this same way, with prose and no code, so the classification stays
|
|
185
|
+
* generic and the message says so — the reason is for the reader, never for
|
|
186
|
+
* this code to parse.
|
|
187
|
+
*/
|
|
188
|
+
function inBandFailure(payload) {
|
|
189
|
+
const status = asRecord(payload)["status"];
|
|
190
|
+
if (status === undefined || status === "success")
|
|
191
|
+
return undefined;
|
|
192
|
+
return new ConnectorCallError("connector_call_failed", `${detailFor(payload, 200)} (status "${String(status)}"). Tithe.ly reports declined payments, unknown ids, and rejected arguments all this way, without a code; read the reason before retrying.`, { retryable: false });
|
|
193
|
+
}
|
|
194
|
+
function tithelyTransport(baseUrl, authenticate) {
|
|
195
|
+
return guardedFetch({
|
|
196
|
+
provider: "Tithe.ly",
|
|
197
|
+
baseUrl,
|
|
198
|
+
headers: { Accept: "application/json" },
|
|
199
|
+
maxResponseBytes: TITHELY_MAX_RESPONSE_BYTES,
|
|
200
|
+
authenticate,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
async function callTithely(send, request, ctx) {
|
|
204
|
+
return await send(request, ctx, async (response) => {
|
|
205
|
+
const parsed = await response.jsonResult();
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
throw tithelyFailure(response.status, response.headers, "value" in parsed ? parsed.value : undefined);
|
|
208
|
+
}
|
|
209
|
+
if (!("value" in parsed)) {
|
|
210
|
+
throw new ConnectorCallError("connector_call_failed", "Tithe.ly returned a successful response that is not JSON.", { retryable: false });
|
|
211
|
+
}
|
|
212
|
+
const refused = inBandFailure(parsed.value);
|
|
213
|
+
if (refused)
|
|
214
|
+
throw refused;
|
|
215
|
+
return parsed.value;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// --- Projection -------------------------------------------------------------------
|
|
219
|
+
/** Integer cents from Tithe.ly's string-or-number amounts. */
|
|
220
|
+
function cents(value) {
|
|
221
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
222
|
+
return Math.trunc(value);
|
|
223
|
+
if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
|
|
224
|
+
return Number(value.trim());
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
/** ISO 8601 UTC from a Unix-seconds string or number. */
|
|
229
|
+
function isoTime(value) {
|
|
230
|
+
const seconds = typeof value === "number"
|
|
231
|
+
? value
|
|
232
|
+
: typeof value === "string" && /^\d+$/.test(value.trim())
|
|
233
|
+
? Number(value.trim())
|
|
234
|
+
: undefined;
|
|
235
|
+
if (seconds === undefined || !Number.isFinite(seconds))
|
|
236
|
+
return undefined;
|
|
237
|
+
const date = new Date(seconds * 1000);
|
|
238
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The entity inside an envelope, with the envelope's own id folded in. Single
|
|
242
|
+
* reads put the id beside `object` rather than inside it.
|
|
243
|
+
*/
|
|
244
|
+
function entity(payload, idKey) {
|
|
245
|
+
const root = asRecord(payload);
|
|
246
|
+
const inner = asRecord(root["object"] ?? root["data"]);
|
|
247
|
+
const record = Object.keys(inner).length > 0 ? inner : root;
|
|
248
|
+
const id = record[idKey] ?? root[idKey];
|
|
249
|
+
return id === undefined ? record : { ...record, [idKey]: id };
|
|
250
|
+
}
|
|
251
|
+
function projectAddress(value) {
|
|
252
|
+
const address = asRecord(value);
|
|
253
|
+
if (Object.keys(address).length === 0)
|
|
254
|
+
return undefined;
|
|
255
|
+
return compact({
|
|
256
|
+
streetAddress: text(address["street_address"]),
|
|
257
|
+
city: text(address["city"]),
|
|
258
|
+
state: text(address["state"]),
|
|
259
|
+
postal: text(address["postal"]),
|
|
260
|
+
country: text(address["country"]),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const FUND_STATUS = {
|
|
264
|
+
"0": "archived",
|
|
265
|
+
"1": "active",
|
|
266
|
+
"2": "hidden",
|
|
267
|
+
};
|
|
268
|
+
function projectFunds(organization) {
|
|
269
|
+
const full = asArray(organization["giving_types_full"]);
|
|
270
|
+
if (full.length > 0) {
|
|
271
|
+
return full.map((value) => {
|
|
272
|
+
const fund = asRecord(value);
|
|
273
|
+
const status = fund["status"] === undefined ? undefined : String(fund["status"]);
|
|
274
|
+
return compact({
|
|
275
|
+
id: fund["id"] === undefined ? undefined : String(fund["id"]),
|
|
276
|
+
name: String(fund["name"] ?? ""),
|
|
277
|
+
status: status === undefined ? undefined : (FUND_STATUS[status] ?? status),
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return asArray(organization["giving_types"])
|
|
282
|
+
.filter((name) => typeof name === "string")
|
|
283
|
+
.map((name) => ({ name }));
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Deliberately omits `bank` (payout bank name and account last four) and
|
|
287
|
+
* `legal` (the legal contact's name and date of birth). Neither answers a
|
|
288
|
+
* giving question, and both are exactly what should not leak into a
|
|
289
|
+
* transcript; `raw: true` returns them for the operator who asks.
|
|
290
|
+
*/
|
|
291
|
+
function projectOrganization(value) {
|
|
292
|
+
const organization = asRecord(value);
|
|
293
|
+
return compact({
|
|
294
|
+
organizationId: text(organization["organization_id"]),
|
|
295
|
+
name: text(organization["name"]),
|
|
296
|
+
ownerAccountId: text(organization["account_id"]),
|
|
297
|
+
website: text(organization["website"]),
|
|
298
|
+
phoneNumber: text(organization["phone_number"]),
|
|
299
|
+
address: projectAddress(organization["address"]),
|
|
300
|
+
payoutCurrency: text(asRecord(organization["bank"])["currency"]),
|
|
301
|
+
createdAt: isoTime(organization["created_date"]),
|
|
302
|
+
funds: projectFunds(organization),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
function projectAccountSummary(value) {
|
|
306
|
+
const account = asRecord(value);
|
|
307
|
+
return compact({
|
|
308
|
+
accountId: text(account["account_id"]),
|
|
309
|
+
firstName: text(account["first_name"]),
|
|
310
|
+
lastName: text(account["last_name"]),
|
|
311
|
+
email: text(account["email"]),
|
|
312
|
+
createdAt: isoTime(account["created_date"]),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
function projectAccount(value) {
|
|
316
|
+
const account = asRecord(value);
|
|
317
|
+
return compact({
|
|
318
|
+
...projectAccountSummary(account),
|
|
319
|
+
phoneNumber: text(account["phone_number"]),
|
|
320
|
+
address: projectAddress(account["address"]),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
/** A nested reference that is sometimes an id string and sometimes an object. */
|
|
324
|
+
function reference(value, idKey) {
|
|
325
|
+
if (typeof value === "string")
|
|
326
|
+
return { id: value, record: {} };
|
|
327
|
+
const record = asRecord(value);
|
|
328
|
+
return { ...defined({ id: text(record[idKey]) }), record };
|
|
329
|
+
}
|
|
330
|
+
/** The donor as a giving record names them; address and phone stay on get_account. */
|
|
331
|
+
function projectDonor(record) {
|
|
332
|
+
const donor = compact({
|
|
333
|
+
firstName: text(record["first_name"]),
|
|
334
|
+
lastName: text(record["last_name"]),
|
|
335
|
+
email: text(record["email"]),
|
|
336
|
+
});
|
|
337
|
+
return Object.keys(donor).length === 0 ? undefined : donor;
|
|
338
|
+
}
|
|
339
|
+
function projectCharge(value) {
|
|
340
|
+
const charge = asRecord(value);
|
|
341
|
+
const organization = reference(charge["organization"], "organization_id");
|
|
342
|
+
const donor = reference(charge["donor_account"], "account_id");
|
|
343
|
+
const method = reference(charge["payment_method"], "pm_id");
|
|
344
|
+
const deposit = charge["deposit_date"];
|
|
345
|
+
return compact({
|
|
346
|
+
chargeId: text(charge["charge_id"]),
|
|
347
|
+
status: text(charge["charge_status"]),
|
|
348
|
+
amountCents: cents(charge["amount"]),
|
|
349
|
+
netAmountCents: cents(charge["net_amount"]),
|
|
350
|
+
feesCents: cents(charge["fees"]),
|
|
351
|
+
currency: text(charge["currency"]),
|
|
352
|
+
fund: text(charge["giving_type"]),
|
|
353
|
+
chargedAt: isoTime(charge["charge_date"]),
|
|
354
|
+
depositedAt: deposit === "pending" ? "pending" : isoTime(deposit),
|
|
355
|
+
recurring: typeof charge["recurring_transaction"] === "boolean" ? charge["recurring_transaction"] : undefined,
|
|
356
|
+
feesCovered: typeof charge["fees_covered"] === "boolean" ? charge["fees_covered"] : undefined,
|
|
357
|
+
memo: text(charge["memo"]),
|
|
358
|
+
organizationId: organization.id,
|
|
359
|
+
organizationName: text(organization.record["name"]),
|
|
360
|
+
accountId: donor.id ?? text(method.record["account_id"]),
|
|
361
|
+
donor: projectDonor(donor.record),
|
|
362
|
+
paymentMethodId: method.id,
|
|
363
|
+
paymentMethod: Object.keys(method.record).length === 0
|
|
364
|
+
? undefined
|
|
365
|
+
: compact({
|
|
366
|
+
type: text(method.record["pm_type"]),
|
|
367
|
+
brand: text(method.record["brand"]),
|
|
368
|
+
last4: text(method.record["last_4_digits"]),
|
|
369
|
+
}),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
function projectRecurring(value) {
|
|
373
|
+
const recurring = asRecord(value);
|
|
374
|
+
const organization = reference(recurring["organization"], "organization_id");
|
|
375
|
+
const donor = reference(recurring["donor_account"], "account_id");
|
|
376
|
+
const method = reference(recurring["payment_method"], "pm_id");
|
|
377
|
+
return compact({
|
|
378
|
+
recurringId: text(recurring["recurring_id"]),
|
|
379
|
+
amountCents: cents(recurring["amount"]),
|
|
380
|
+
currency: text(recurring["currency"]),
|
|
381
|
+
fund: text(recurring["giving_type"]),
|
|
382
|
+
term: text(recurring["term"]),
|
|
383
|
+
startAt: isoTime(recurring["start_date"]),
|
|
384
|
+
organizationId: organization.id,
|
|
385
|
+
organizationName: text(organization.record["name"]),
|
|
386
|
+
accountId: donor.id,
|
|
387
|
+
donor: projectDonor(donor.record),
|
|
388
|
+
paymentMethodId: method.id,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
function projectPaymentMethod(value) {
|
|
392
|
+
const method = asRecord(value);
|
|
393
|
+
return compact({
|
|
394
|
+
paymentMethodId: text(method["pm_id"]),
|
|
395
|
+
accountId: text(method["account_id"]),
|
|
396
|
+
type: text(method["pm_type"]),
|
|
397
|
+
brand: text(method["brand"]),
|
|
398
|
+
last4: text(method["last_4_digits"]),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Fetch one page by asking for `limit + 1` rows: the extra row is the only
|
|
403
|
+
* honest `hasMore` Tithe.ly allows, since it returns no flag and a full page
|
|
404
|
+
* proves nothing. The cursor is the id of the last row kept, which is exactly
|
|
405
|
+
* what Tithe.ly's `starting_after` takes.
|
|
406
|
+
*/
|
|
407
|
+
async function listPage(send, ctx, path, idKey, args, defaultPageSize, filters = {}) {
|
|
408
|
+
const limit = args["limit"] ?? defaultPageSize;
|
|
409
|
+
const payload = await callTithely(send, {
|
|
410
|
+
method: "GET",
|
|
411
|
+
path,
|
|
412
|
+
query: {
|
|
413
|
+
...filters,
|
|
414
|
+
limit: limit + 1,
|
|
415
|
+
order_by: args["order"] === "asc" ? "ASC" : "DESC",
|
|
416
|
+
starting_after: args["cursor"],
|
|
417
|
+
},
|
|
418
|
+
}, ctx);
|
|
419
|
+
const data = asRecord(payload)["data"];
|
|
420
|
+
if (data !== undefined && !Array.isArray(data)) {
|
|
421
|
+
throw new ConnectorCallError("connector_call_failed", "Tithe.ly returned a list response whose data is not an array.", { retryable: false });
|
|
422
|
+
}
|
|
423
|
+
const all = data ?? [];
|
|
424
|
+
const rows = all.slice(0, limit);
|
|
425
|
+
const hasMore = all.length > limit;
|
|
426
|
+
let nextCursor = null;
|
|
427
|
+
if (hasMore) {
|
|
428
|
+
nextCursor = text(asRecord(rows[rows.length - 1])[idKey]) ?? null;
|
|
429
|
+
if (nextCursor === null) {
|
|
430
|
+
throw new ConnectorCallError("connector_call_failed", `Tithe.ly returned a page whose last row has no ${idKey}, so the next page cannot be addressed.`, { retryable: false });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return { rows, page: { hasMore, nextCursor } };
|
|
434
|
+
}
|
|
435
|
+
/** An ISO 8601 date-time with no `Z` or offset, which `Date.parse` reads as local time. */
|
|
436
|
+
const OFFSETLESS_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/;
|
|
437
|
+
/**
|
|
438
|
+
* Unix seconds from an integer or an ISO 8601 date/date-time. A date-time
|
|
439
|
+
* without an offset is UTC, as the schema promises, not the runtime's zone.
|
|
440
|
+
*/
|
|
441
|
+
function unixSeconds(value, name) {
|
|
442
|
+
if (value === undefined)
|
|
443
|
+
return undefined;
|
|
444
|
+
if (typeof value === "number")
|
|
445
|
+
return value;
|
|
446
|
+
const raw = String(value).trim();
|
|
447
|
+
if (/^\d+$/.test(raw))
|
|
448
|
+
return Number(raw);
|
|
449
|
+
const parsed = Date.parse(OFFSETLESS_DATE_TIME.test(raw) ? `${raw}Z` : raw);
|
|
450
|
+
if (!Number.isFinite(parsed)) {
|
|
451
|
+
throw new ConnectorCallError("invalid_args", `${name} must be Unix seconds or an ISO 8601 date or date-time; received "${raw}".`);
|
|
452
|
+
}
|
|
453
|
+
return Math.floor(parsed / 1000);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Tithe.ly lists charges and recurring gifts only for a donor or an
|
|
457
|
+
* organization, so a call naming neither can only fail: refuse it locally.
|
|
458
|
+
*/
|
|
459
|
+
function scopeFilters(args, noun) {
|
|
460
|
+
if (args["organizationId"] === undefined && args["accountId"] === undefined) {
|
|
461
|
+
throw new ConnectorCallError("invalid_args", `Tithe.ly lists ${noun} only for an organization or a donor: pass organizationId (from list_organizations) or accountId (from list_accounts).`);
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
organization_id: args["organizationId"],
|
|
465
|
+
account_id: args["accountId"],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
// --- Schemas ----------------------------------------------------------------------
|
|
469
|
+
function namedInput(properties, required) {
|
|
470
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
471
|
+
}
|
|
472
|
+
const RAW_PROPERTY = {
|
|
473
|
+
type: "boolean",
|
|
474
|
+
description: "Return Tithe.ly's untouched records instead of the lean projection.",
|
|
475
|
+
};
|
|
476
|
+
const CURSOR_PROPERTY = {
|
|
477
|
+
type: "string",
|
|
478
|
+
minLength: 1,
|
|
479
|
+
description: "Opaque nextCursor from the previous page. Pass it back unchanged.",
|
|
480
|
+
};
|
|
481
|
+
const ORDER_PROPERTY = {
|
|
482
|
+
type: "string",
|
|
483
|
+
enum: ["desc", "asc"],
|
|
484
|
+
description: "Newest first (desc, Tithe.ly's default) or oldest first. Keep it constant while paging.",
|
|
485
|
+
};
|
|
486
|
+
function limitProperty(defaultPageSize) {
|
|
487
|
+
return {
|
|
488
|
+
type: "integer",
|
|
489
|
+
minimum: 1,
|
|
490
|
+
maximum: MAX_PAGE_SIZE,
|
|
491
|
+
description: `Rows per page, 1 to ${MAX_PAGE_SIZE} (Tithe.ly allows 100; connecta asks for one extra to compute hasMore). Defaults to ${defaultPageSize}.`,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
const ORGANIZATION_ID_PROPERTY = {
|
|
495
|
+
type: "string",
|
|
496
|
+
minLength: 1,
|
|
497
|
+
description: "Organization id (org_…) from list_organizations.",
|
|
498
|
+
};
|
|
499
|
+
const ACCOUNT_ID_PROPERTY = {
|
|
500
|
+
type: "string",
|
|
501
|
+
minLength: 1,
|
|
502
|
+
description: "Donor account id (user_…) from list_accounts.",
|
|
503
|
+
};
|
|
504
|
+
const PAGE_SCHEMA = {
|
|
505
|
+
type: "object",
|
|
506
|
+
properties: {
|
|
507
|
+
hasMore: { type: "boolean" },
|
|
508
|
+
nextCursor: { type: ["string", "null"] },
|
|
509
|
+
},
|
|
510
|
+
required: ["hasMore", "nextCursor"],
|
|
511
|
+
};
|
|
512
|
+
function listSchema(key, item) {
|
|
513
|
+
return {
|
|
514
|
+
type: "object",
|
|
515
|
+
properties: { [key]: { type: "array", items: item }, page: PAGE_SCHEMA },
|
|
516
|
+
required: [key, "page"],
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
const ADDRESS_SCHEMA = {
|
|
520
|
+
type: "object",
|
|
521
|
+
properties: {
|
|
522
|
+
streetAddress: { type: "string" },
|
|
523
|
+
city: { type: "string" },
|
|
524
|
+
state: { type: "string" },
|
|
525
|
+
postal: { type: "string" },
|
|
526
|
+
country: { type: "string" },
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
const DONOR_SCHEMA = {
|
|
530
|
+
type: "object",
|
|
531
|
+
properties: {
|
|
532
|
+
firstName: { type: "string" },
|
|
533
|
+
lastName: { type: "string" },
|
|
534
|
+
email: { type: "string" },
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
const ORGANIZATION_SCHEMA = {
|
|
538
|
+
type: "object",
|
|
539
|
+
properties: {
|
|
540
|
+
organizationId: { type: "string" },
|
|
541
|
+
name: { type: "string" },
|
|
542
|
+
ownerAccountId: { type: "string" },
|
|
543
|
+
website: { type: "string" },
|
|
544
|
+
phoneNumber: { type: "string" },
|
|
545
|
+
address: ADDRESS_SCHEMA,
|
|
546
|
+
payoutCurrency: { type: "string" },
|
|
547
|
+
createdAt: { type: "string" },
|
|
548
|
+
funds: {
|
|
549
|
+
type: "array",
|
|
550
|
+
items: {
|
|
551
|
+
type: "object",
|
|
552
|
+
properties: {
|
|
553
|
+
id: { type: "string" },
|
|
554
|
+
name: { type: "string" },
|
|
555
|
+
status: { type: "string" },
|
|
556
|
+
},
|
|
557
|
+
required: ["name"],
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
required: ["funds"],
|
|
562
|
+
};
|
|
563
|
+
const ACCOUNT_SUMMARY_SCHEMA = {
|
|
564
|
+
type: "object",
|
|
565
|
+
properties: {
|
|
566
|
+
accountId: { type: "string" },
|
|
567
|
+
firstName: { type: "string" },
|
|
568
|
+
lastName: { type: "string" },
|
|
569
|
+
email: { type: "string" },
|
|
570
|
+
createdAt: { type: "string" },
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
const ACCOUNT_SCHEMA = {
|
|
574
|
+
type: "object",
|
|
575
|
+
properties: {
|
|
576
|
+
...ACCOUNT_SUMMARY_SCHEMA["properties"],
|
|
577
|
+
phoneNumber: { type: "string" },
|
|
578
|
+
address: ADDRESS_SCHEMA,
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
const CHARGE_SCHEMA = {
|
|
582
|
+
type: "object",
|
|
583
|
+
properties: {
|
|
584
|
+
chargeId: { type: "string" },
|
|
585
|
+
status: { type: "string" },
|
|
586
|
+
amountCents: { type: "integer" },
|
|
587
|
+
netAmountCents: { type: "integer" },
|
|
588
|
+
feesCents: { type: "integer" },
|
|
589
|
+
currency: { type: "string" },
|
|
590
|
+
fund: { type: "string" },
|
|
591
|
+
chargedAt: { type: "string" },
|
|
592
|
+
depositedAt: { type: "string" },
|
|
593
|
+
recurring: { type: "boolean" },
|
|
594
|
+
feesCovered: { type: "boolean" },
|
|
595
|
+
memo: { type: "string" },
|
|
596
|
+
organizationId: { type: "string" },
|
|
597
|
+
organizationName: { type: "string" },
|
|
598
|
+
accountId: { type: "string" },
|
|
599
|
+
donor: DONOR_SCHEMA,
|
|
600
|
+
paymentMethodId: { type: "string" },
|
|
601
|
+
paymentMethod: {
|
|
602
|
+
type: "object",
|
|
603
|
+
properties: {
|
|
604
|
+
type: { type: "string" },
|
|
605
|
+
brand: { type: "string" },
|
|
606
|
+
last4: { type: "string" },
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
const RECURRING_SCHEMA = {
|
|
612
|
+
type: "object",
|
|
613
|
+
properties: {
|
|
614
|
+
recurringId: { type: "string" },
|
|
615
|
+
amountCents: { type: "integer" },
|
|
616
|
+
currency: { type: "string" },
|
|
617
|
+
fund: { type: "string" },
|
|
618
|
+
term: { type: "string" },
|
|
619
|
+
startAt: { type: "string" },
|
|
620
|
+
organizationId: { type: "string" },
|
|
621
|
+
organizationName: { type: "string" },
|
|
622
|
+
accountId: { type: "string" },
|
|
623
|
+
donor: DONOR_SCHEMA,
|
|
624
|
+
paymentMethodId: { type: "string" },
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
const PAYMENT_METHOD_SCHEMA = {
|
|
628
|
+
type: "object",
|
|
629
|
+
properties: {
|
|
630
|
+
paymentMethodId: { type: "string" },
|
|
631
|
+
accountId: { type: "string" },
|
|
632
|
+
type: { type: "string" },
|
|
633
|
+
brand: { type: "string" },
|
|
634
|
+
last4: { type: "string" },
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
const PATH_PROPERTY = {
|
|
638
|
+
type: "string",
|
|
639
|
+
minLength: 1,
|
|
640
|
+
maxLength: 512,
|
|
641
|
+
description: "Path below /api/v1 beginning with '/', such as /charges-list or /refunds/ch_123. No query string.",
|
|
642
|
+
};
|
|
643
|
+
function pairsProperty(description, value) {
|
|
644
|
+
return {
|
|
645
|
+
type: "array",
|
|
646
|
+
maxItems: 50,
|
|
647
|
+
description,
|
|
648
|
+
items: {
|
|
649
|
+
type: "object",
|
|
650
|
+
properties: {
|
|
651
|
+
name: { type: "string", minLength: 1, description: "Tithe.ly's snake_case parameter name." },
|
|
652
|
+
value: { type: ["string", "number", "boolean"], description: value },
|
|
653
|
+
},
|
|
654
|
+
required: ["name", "value"],
|
|
655
|
+
additionalProperties: false,
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
const QUERY_PROPERTY = pairsProperty("Query parameters as name/value pairs, e.g. account_id.", "Parameter value; stringified once by the transport.");
|
|
660
|
+
const FORM_PROPERTY = pairsProperty("Form fields as name/value pairs, sent form-encoded as Tithe.ly's documented examples send them. Amounts are integer cents.", "Field value; stringified once by the transport.");
|
|
661
|
+
function pairs(value) {
|
|
662
|
+
const out = {};
|
|
663
|
+
for (const row of asArray(value)) {
|
|
664
|
+
const pair = asRecord(row);
|
|
665
|
+
if (typeof pair["name"] !== "string")
|
|
666
|
+
continue;
|
|
667
|
+
const item = pair["value"];
|
|
668
|
+
if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") {
|
|
669
|
+
out[pair["name"]] = String(item);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
674
|
+
const UNTOUCHED_RESULT = {
|
|
675
|
+
type: "object",
|
|
676
|
+
properties: { result: { description: "Tithe.ly's untouched response body, or null when empty." } },
|
|
677
|
+
required: ["result"],
|
|
678
|
+
};
|
|
679
|
+
// --- Tools ------------------------------------------------------------------------
|
|
680
|
+
function tools(send, defaultPageSize) {
|
|
681
|
+
const readOnly = { readOnlyHint: true };
|
|
682
|
+
const destructive = { readOnlyHint: false, destructiveHint: true };
|
|
683
|
+
const limit = limitProperty(defaultPageSize);
|
|
684
|
+
const raw = (args) => args["raw"] === true;
|
|
685
|
+
return [
|
|
686
|
+
{
|
|
687
|
+
name: "tithely_api_get",
|
|
688
|
+
description: "Call any Tithe.ly v1 GET endpoint and return its untouched response. Prefer named reads: raw organization records include bank last-4 and legal-contact DOB.",
|
|
689
|
+
annotations: readOnly,
|
|
690
|
+
inputSchema: namedInput({ path: PATH_PROPERTY, query: QUERY_PROPERTY }, ["path"]),
|
|
691
|
+
outputSchema: UNTOUCHED_RESULT,
|
|
692
|
+
handler: async (args, ctx) => ({
|
|
693
|
+
result: (await callTithely(send, { method: "GET", path: String(args["path"]), query: pairs(args["query"]) }, ctx)) ??
|
|
694
|
+
null,
|
|
695
|
+
}),
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
name: "tithely_api_mutate",
|
|
699
|
+
description: "Send a form-encoded Tithe.ly v1 POST or DELETE: charges, refunds, recurring gifts, payment methods, accounts. Moves money or donor payment state; always approval-gated.",
|
|
700
|
+
annotations: destructive,
|
|
701
|
+
inputSchema: namedInput({
|
|
702
|
+
method: {
|
|
703
|
+
type: "string",
|
|
704
|
+
enum: ["POST", "DELETE"],
|
|
705
|
+
description: "POST creates, updates, charges, or refunds; DELETE cancels a recurring gift or removes a payment method.",
|
|
706
|
+
},
|
|
707
|
+
path: PATH_PROPERTY,
|
|
708
|
+
query: QUERY_PROPERTY,
|
|
709
|
+
form: FORM_PROPERTY,
|
|
710
|
+
}, ["method", "path"]),
|
|
711
|
+
outputSchema: UNTOUCHED_RESULT,
|
|
712
|
+
handler: async (args, ctx) => {
|
|
713
|
+
const form = pairs(args["form"]);
|
|
714
|
+
const hasForm = Object.keys(form).length > 0;
|
|
715
|
+
return {
|
|
716
|
+
result: (await callTithely(send, {
|
|
717
|
+
method: args["method"],
|
|
718
|
+
path: String(args["path"]),
|
|
719
|
+
query: pairs(args["query"]),
|
|
720
|
+
...(hasForm
|
|
721
|
+
? {
|
|
722
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
723
|
+
rawBody: new URLSearchParams(form).toString(),
|
|
724
|
+
}
|
|
725
|
+
: {}),
|
|
726
|
+
}, ctx)) ?? null,
|
|
727
|
+
};
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
name: "list_organizations",
|
|
732
|
+
description: "List the organizations these keys manage, with their funds (giving types) and status. Omits payout bank and legal-contact details.",
|
|
733
|
+
annotations: readOnly,
|
|
734
|
+
inputSchema: namedInput({ limit, cursor: CURSOR_PROPERTY, order: ORDER_PROPERTY, raw: RAW_PROPERTY }, []),
|
|
735
|
+
outputSchema: listSchema("organizations", ORGANIZATION_SCHEMA),
|
|
736
|
+
handler: async (args, ctx) => {
|
|
737
|
+
const { rows, page } = await listPage(send, ctx, "/organizations-list", "organization_id", args, defaultPageSize);
|
|
738
|
+
return { organizations: raw(args) ? rows : rows.map(projectOrganization), page };
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
name: "get_organization",
|
|
743
|
+
description: "Get one organization with its funds (giving types) and whether each is active, hidden, or archived. Omits payout bank and legal-contact details.",
|
|
744
|
+
annotations: readOnly,
|
|
745
|
+
inputSchema: namedInput({ organizationId: ORGANIZATION_ID_PROPERTY, raw: RAW_PROPERTY }, ["organizationId"]),
|
|
746
|
+
outputSchema: ORGANIZATION_SCHEMA,
|
|
747
|
+
handler: async (args, ctx) => {
|
|
748
|
+
const payload = await callTithely(send, { method: "GET", path: `/organizations/${encodeURIComponent(args["organizationId"])}` }, ctx);
|
|
749
|
+
const record = entity(payload, "organization_id");
|
|
750
|
+
return raw(args) ? record : projectOrganization(record);
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
name: "list_accounts",
|
|
755
|
+
description: "List donor accounts by name, email, and id. Contact details stay on get_account; giving history is list_charges by accountId.",
|
|
756
|
+
annotations: readOnly,
|
|
757
|
+
inputSchema: namedInput({
|
|
758
|
+
scope: {
|
|
759
|
+
type: "string",
|
|
760
|
+
enum: ["all", "api"],
|
|
761
|
+
description: "all (default) lists every account Tithe.ly lets these keys see; api lists only accounts created through this API.",
|
|
762
|
+
},
|
|
763
|
+
limit,
|
|
764
|
+
cursor: CURSOR_PROPERTY,
|
|
765
|
+
order: ORDER_PROPERTY,
|
|
766
|
+
raw: RAW_PROPERTY,
|
|
767
|
+
}, []),
|
|
768
|
+
outputSchema: listSchema("accounts", ACCOUNT_SUMMARY_SCHEMA),
|
|
769
|
+
handler: async (args, ctx) => {
|
|
770
|
+
const path = args["scope"] === "api" ? "/accounts-list" : "/accounts-list-all";
|
|
771
|
+
const { rows, page } = await listPage(send, ctx, path, "account_id", args, defaultPageSize);
|
|
772
|
+
return { accounts: raw(args) ? rows : rows.map(projectAccountSummary), page };
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
name: "get_account",
|
|
777
|
+
description: "Get one donor account with email, phone, and postal address. Does not include giving; use list_charges with accountId.",
|
|
778
|
+
annotations: readOnly,
|
|
779
|
+
inputSchema: namedInput({ accountId: ACCOUNT_ID_PROPERTY, raw: RAW_PROPERTY }, ["accountId"]),
|
|
780
|
+
outputSchema: ACCOUNT_SCHEMA,
|
|
781
|
+
handler: async (args, ctx) => {
|
|
782
|
+
const payload = await callTithely(send, { method: "GET", path: `/accounts/${encodeURIComponent(args["accountId"])}` }, ctx);
|
|
783
|
+
const record = entity(payload, "account_id");
|
|
784
|
+
return raw(args) ? record : projectAccount(record);
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
name: "list_charges",
|
|
789
|
+
description: "List gifts (charges) to one organization or from one donor, optionally within a created-date range, with gross, net, and fees in cents.",
|
|
790
|
+
annotations: readOnly,
|
|
791
|
+
inputSchema: namedInput({
|
|
792
|
+
organizationId: {
|
|
793
|
+
...ORGANIZATION_ID_PROPERTY,
|
|
794
|
+
description: "Organization (org_…) the gifts went to. This or accountId is required.",
|
|
795
|
+
},
|
|
796
|
+
accountId: {
|
|
797
|
+
...ACCOUNT_ID_PROPERTY,
|
|
798
|
+
description: "Donor account (user_…) that gave. This or organizationId is required.",
|
|
799
|
+
},
|
|
800
|
+
createdAfter: {
|
|
801
|
+
type: ["integer", "string"],
|
|
802
|
+
minimum: 0,
|
|
803
|
+
minLength: 1,
|
|
804
|
+
description: "Only charges created after this time: Unix seconds or an ISO 8601 date/date-time (UTC when no offset).",
|
|
805
|
+
},
|
|
806
|
+
createdBefore: {
|
|
807
|
+
type: ["integer", "string"],
|
|
808
|
+
minimum: 0,
|
|
809
|
+
minLength: 1,
|
|
810
|
+
description: "Only charges created before this time, in the same forms as createdAfter.",
|
|
811
|
+
},
|
|
812
|
+
limit,
|
|
813
|
+
cursor: CURSOR_PROPERTY,
|
|
814
|
+
order: ORDER_PROPERTY,
|
|
815
|
+
raw: RAW_PROPERTY,
|
|
816
|
+
}, []),
|
|
817
|
+
outputSchema: listSchema("charges", CHARGE_SCHEMA),
|
|
818
|
+
handler: async (args, ctx) => {
|
|
819
|
+
const after = unixSeconds(args["createdAfter"], "createdAfter");
|
|
820
|
+
const before = unixSeconds(args["createdBefore"], "createdBefore");
|
|
821
|
+
if (after !== undefined && before !== undefined && after > before) {
|
|
822
|
+
throw new ConnectorCallError("invalid_args", "createdAfter is later than createdBefore, so no charge can match.");
|
|
823
|
+
}
|
|
824
|
+
const { rows, page } = await listPage(send, ctx, "/charges-list", "charge_id", args, defaultPageSize, {
|
|
825
|
+
...scopeFilters(args, "charges"),
|
|
826
|
+
created_after: after,
|
|
827
|
+
created_before: before,
|
|
828
|
+
});
|
|
829
|
+
return { charges: raw(args) ? rows : rows.map(projectCharge), page };
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
name: "get_charge",
|
|
834
|
+
description: "Get one charge with amounts in cents, fund, deposit date, donor name and email, and payment-method brand and last four.",
|
|
835
|
+
annotations: readOnly,
|
|
836
|
+
inputSchema: namedInput({
|
|
837
|
+
chargeId: { type: "string", minLength: 1, description: "Charge id (ch_…) from list_charges." },
|
|
838
|
+
raw: RAW_PROPERTY,
|
|
839
|
+
}, ["chargeId"]),
|
|
840
|
+
outputSchema: CHARGE_SCHEMA,
|
|
841
|
+
handler: async (args, ctx) => {
|
|
842
|
+
const payload = await callTithely(send, { method: "GET", path: `/charges/${encodeURIComponent(args["chargeId"])}` }, ctx);
|
|
843
|
+
const record = entity(payload, "charge_id");
|
|
844
|
+
return raw(args) ? record : projectCharge(record);
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
name: "list_recurring_charges",
|
|
849
|
+
description: "List recurring gifts for one organization or one donor: amount, fund, term, and start date. Does not list the charges they produced.",
|
|
850
|
+
annotations: readOnly,
|
|
851
|
+
inputSchema: namedInput({
|
|
852
|
+
organizationId: {
|
|
853
|
+
...ORGANIZATION_ID_PROPERTY,
|
|
854
|
+
description: "Organization (org_…) receiving the gifts. This or accountId is required.",
|
|
855
|
+
},
|
|
856
|
+
accountId: {
|
|
857
|
+
...ACCOUNT_ID_PROPERTY,
|
|
858
|
+
description: "Donor account (user_…) giving. This or organizationId is required.",
|
|
859
|
+
},
|
|
860
|
+
limit,
|
|
861
|
+
cursor: CURSOR_PROPERTY,
|
|
862
|
+
order: ORDER_PROPERTY,
|
|
863
|
+
raw: RAW_PROPERTY,
|
|
864
|
+
}, []),
|
|
865
|
+
outputSchema: listSchema("recurringCharges", RECURRING_SCHEMA),
|
|
866
|
+
handler: async (args, ctx) => {
|
|
867
|
+
const { rows, page } = await listPage(send, ctx, "/recurring-list", "recurring_id", args, defaultPageSize, scopeFilters(args, "recurring gifts"));
|
|
868
|
+
return { recurringCharges: raw(args) ? rows : rows.map(projectRecurring), page };
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
{
|
|
872
|
+
name: "get_recurring_charge",
|
|
873
|
+
description: "Get one recurring gift by id: amount in cents, fund, term, start date, organization, and donor name and email.",
|
|
874
|
+
annotations: readOnly,
|
|
875
|
+
inputSchema: namedInput({
|
|
876
|
+
recurringId: {
|
|
877
|
+
type: "string",
|
|
878
|
+
minLength: 1,
|
|
879
|
+
description: "Recurring gift id (rc_…) from list_recurring_charges.",
|
|
880
|
+
},
|
|
881
|
+
raw: RAW_PROPERTY,
|
|
882
|
+
}, ["recurringId"]),
|
|
883
|
+
outputSchema: RECURRING_SCHEMA,
|
|
884
|
+
handler: async (args, ctx) => {
|
|
885
|
+
const payload = await callTithely(send, { method: "GET", path: `/recurring/${encodeURIComponent(args["recurringId"])}` }, ctx);
|
|
886
|
+
const record = entity(payload, "recurring_id");
|
|
887
|
+
return raw(args) ? record : projectRecurring(record);
|
|
888
|
+
},
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
name: "list_payment_methods",
|
|
892
|
+
description: "List one donor's stored payment methods as type, brand, and last four. Every method in one response; never card numbers or tokens.",
|
|
893
|
+
annotations: readOnly,
|
|
894
|
+
inputSchema: namedInput({ accountId: ACCOUNT_ID_PROPERTY, raw: RAW_PROPERTY }, ["accountId"]),
|
|
895
|
+
outputSchema: {
|
|
896
|
+
type: "object",
|
|
897
|
+
properties: { paymentMethods: { type: "array", items: PAYMENT_METHOD_SCHEMA } },
|
|
898
|
+
required: ["paymentMethods"],
|
|
899
|
+
},
|
|
900
|
+
handler: async (args, ctx) => {
|
|
901
|
+
const payload = await callTithely(send, { method: "GET", path: "/payment-methods-list", query: { account_id: args["accountId"] } }, ctx);
|
|
902
|
+
const rows = asArray(asRecord(payload)["data"]);
|
|
903
|
+
return { paymentMethods: raw(args) ? rows : rows.map(projectPaymentMethod) };
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
];
|
|
907
|
+
}
|
|
908
|
+
// --- Guide ------------------------------------------------------------------------
|
|
909
|
+
function usageGuide(purpose, environment, instructions) {
|
|
910
|
+
const accountInstructions = instructions?.trim();
|
|
911
|
+
const where = environment === "live"
|
|
912
|
+
? "Live Tithe.ly (tithe.ly): real donors and real money."
|
|
913
|
+
: "Test Tithe.ly (tithelydev.com): a test account; no real money moves.";
|
|
914
|
+
return `# Tithe.ly giving usage
|
|
915
|
+
|
|
916
|
+
${where}
|
|
917
|
+
|
|
918
|
+
Account purpose: ${purpose}${skill.fragments.guide_0}${accountInstructions ? `\n## ${skill.instructionsHeading}\n\n${accountInstructions}\n` : ""}`;
|
|
919
|
+
}
|
|
920
|
+
// --- Construction -----------------------------------------------------------------
|
|
921
|
+
async function testKeyPair(send, environment, values, ctx) {
|
|
922
|
+
const pair = keyPairProblem(values);
|
|
923
|
+
// A mismatched pair is refused here, before any request leaves.
|
|
924
|
+
if (typeof pair === "string")
|
|
925
|
+
return { ok: false, message: pair };
|
|
926
|
+
try {
|
|
927
|
+
const payload = await callTithely(send, { method: "GET", path: "/organizations-list", query: { limit: 1 } }, {
|
|
928
|
+
...ctx,
|
|
929
|
+
credential: {
|
|
930
|
+
get: async (field) => (field ? (values[field] ?? null) : null),
|
|
931
|
+
getAll: async () => values,
|
|
932
|
+
},
|
|
933
|
+
});
|
|
934
|
+
const first = asRecord(asArray(asRecord(payload)["data"])[0]);
|
|
935
|
+
const name = text(first["name"]);
|
|
936
|
+
const id = text(first["organization_id"]);
|
|
937
|
+
return {
|
|
938
|
+
ok: true,
|
|
939
|
+
message: name
|
|
940
|
+
? `Keys accepted by Tithe.ly (${environment}); first organization reached: ${name}${id ? ` (${id})` : ""}.`
|
|
941
|
+
: `Keys accepted by Tithe.ly (${environment}); no organization is visible to them.`,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
catch (error) {
|
|
945
|
+
return {
|
|
946
|
+
ok: false,
|
|
947
|
+
message: error instanceof ConnectorCallError ? error.message : "Tithe.ly rejected the keys.",
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
/** The closed options tithely() accepts; see `assertKnownOptions`. */
|
|
952
|
+
const TITHELY_OPTIONS = optionsOf()({
|
|
953
|
+
...PROVIDER_COMMON,
|
|
954
|
+
...keys("environment", "defaultPageSize", "baseUrl"),
|
|
955
|
+
});
|
|
956
|
+
/** A maintained Tithe.ly giving connection over the v1 REST API. */
|
|
957
|
+
export const tithely = asProviderFactory({
|
|
958
|
+
name: "tithely",
|
|
959
|
+
title: "Tithe.ly",
|
|
960
|
+
kind: "api",
|
|
961
|
+
readme: "Tithe.ly",
|
|
962
|
+
bundle: { "baselineGzip": 18076, "maxGzip": 78076 },
|
|
963
|
+
skill,
|
|
964
|
+
options: TITHELY_OPTIONS,
|
|
965
|
+
create: tithelyConnector,
|
|
966
|
+
});
|
|
967
|
+
function tithelyConnector(id, options) {
|
|
968
|
+
const purpose = options.purpose?.trim();
|
|
969
|
+
if (!purpose) {
|
|
970
|
+
throw new Error("tithely() requires a non-empty account purpose.");
|
|
971
|
+
}
|
|
972
|
+
const environment = options.environment;
|
|
973
|
+
if (environment !== "live" && environment !== "test") {
|
|
974
|
+
throw new Error('tithely() requires environment: "live" (tithe.ly) or "test" (tithelydev.com). Tithe.ly keys are environment-specific and unrecognizable, so connecta will not guess.');
|
|
975
|
+
}
|
|
976
|
+
const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE;
|
|
977
|
+
if (!Number.isInteger(defaultPageSize) || defaultPageSize < 1 || defaultPageSize > MAX_PAGE_SIZE) {
|
|
978
|
+
throw new Error(`tithely() defaultPageSize must be a whole number between 1 and ${MAX_PAGE_SIZE}.`);
|
|
979
|
+
}
|
|
980
|
+
const send = tithelyTransport(options.baseUrl?.trim() || TITHELY_API_BASE_URLS[environment], operatorKeyPair);
|
|
981
|
+
const live = environment === "live";
|
|
982
|
+
return api(id, {
|
|
983
|
+
...defined({
|
|
984
|
+
authScope: options.authScope,
|
|
985
|
+
callAdmission: options.callAdmission,
|
|
986
|
+
maxResultBytes: options.maxResultBytes,
|
|
987
|
+
}),
|
|
988
|
+
title: options.title ?? (live ? "Tithe.ly Giving" : "Tithe.ly Giving (test)"),
|
|
989
|
+
description: live
|
|
990
|
+
? `Tithe.ly giving — live donors and real money — ${purpose}`
|
|
991
|
+
: `Tithe.ly giving (test environment, no real money) — ${purpose}`,
|
|
992
|
+
credential: KEY_PAIR_CREDENTIAL,
|
|
993
|
+
testCredentials: (values, ctx) => testKeyPair(send, environment, values, ctx),
|
|
994
|
+
usageGuide: {
|
|
995
|
+
content: usageGuide(purpose, environment, options.instructions),
|
|
996
|
+
summary: `${live ? "Live" : "Test"} giving: donor-data reduction, cents and ISO dates, cursor paging, and the form-encoded write hatch.`,
|
|
997
|
+
// Required: the reduction rule for donor data and the write map behind
|
|
998
|
+
// the mutate hatch live only here. No schema can carry either.
|
|
999
|
+
required: true,
|
|
1000
|
+
},
|
|
1001
|
+
tools: tools(send, defaultPageSize),
|
|
1002
|
+
});
|
|
1003
|
+
}
|