@zackbart/connecta 0.36.0 → 0.36.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.
- package/CHANGELOG.md +42 -0
- package/README.md +1 -1
- package/bin/version.mjs +1 -1
- package/dist/http/hatch-paging.js +2 -2
- package/dist/http/paging.js +3 -88
- package/dist/http/provider.js +1 -2
- package/dist/http/tools.js +1 -23
- package/dist/program-writes.js +0 -1
- package/dist/providers/_shared/sensitive-fields.js +0 -16
- package/dist/providers/breeze/api.js +2 -2
- package/dist/providers/ccb/api.js +1 -1
- package/dist/providers/cloudflare/api.js +8 -8
- package/dist/providers/cloudflare/lib/config.d.ts +2 -1
- package/dist/providers/docs/api.js +1 -1
- package/dist/providers/drive/api.js +1 -1
- package/dist/providers/drive/lib/hatch.js +1 -1
- package/dist/providers/drive/tools.js +1 -1
- package/dist/providers/forms/api.js +1 -1
- package/dist/providers/forms/lib/hatch.js +1 -1
- package/dist/providers/forms/tools.js +1 -1
- package/dist/providers/github/api.js +1 -1
- package/dist/providers/github/lib/discovery.js +2 -2
- package/dist/providers/gmail/api.js +1 -1
- package/dist/providers/granola/api.js +155 -0
- package/dist/providers/granola/index.d.ts +10 -0
- package/dist/providers/granola/index.js +68 -0
- package/dist/providers/granola/openapi.generated.js +30 -0
- package/dist/providers/granola/safety.js +37 -0
- package/dist/providers/granola/skill.generated.js +5 -0
- package/dist/providers/granola/tools.js +2 -0
- package/dist/providers/overflow/api.js +26 -26
- package/dist/providers/planning-center/api.js +4 -4
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/lib/json-api.js +2 -2
- package/dist/providers/quickbooks/lib/query.js +1 -1
- package/dist/providers/quickbooks/safety.js +1 -1
- package/dist/providers/sheets/api.js +1 -1
- package/dist/providers/sheets/lib/hatch.js +2 -2
- package/dist/providers/sheets/lib/requests.js +1 -1
- package/dist/providers/slides/api.js +1 -1
- package/dist/providers/stripe/api.js +7 -7
- package/dist/providers/tithely/api.js +2 -2
- package/dist/providers/vercel/api.js +12 -12
- package/dist/providers/vercel/lib/pin.js +1 -1
- package/dist/routes/mcp.js +1 -1
- package/dist/runtime/admission.js +0 -12
- package/dist/storage/keys.js +2 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +124 -74
- package/documentation/auth.md +9 -2
- package/documentation/code-mode.md +6 -6
- package/documentation/meta-tools.md +5 -2
- package/documentation/operator-tests.md +11 -2
- package/documentation/operator-ui.md +1 -2
- package/documentation/providers.md +110 -0
- package/package.json +6 -2
- package/templates/node/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this package are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.36.1 — 2026-10-27
|
|
6
|
+
|
|
7
|
+
Connecta 0.36.1 adds read-only Granola workspace API access and lets Eve agents select a pool before each model call. Pool changes keep guidance caches separate and prevent replay of parked calls against a different pool. Existing static-pool configuration and public provider tools keep their behavior.
|
|
8
|
+
|
|
9
|
+
The contributor checks now center on real Node/Workers, browser, persistence and installed-package journeys. Every PR runs browser, audit, Eve and Docker package checks. This patch requires no deployment configuration migration. Granola operators supply a workspace key; Eve pool selectors are optional.
|
|
10
|
+
|
|
11
|
+
QuickBooks invoice updates remain unavailable while their delivery contract is unresolved. The workload validation recipe is reproducible locally; live validation requires separate authorization and is not claimed by this release.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Let the Eve helper choose a pool before each model call with an app-owned selector, fence parked calls after pool changes, and cache instructions and failure backoff per endpoint. Instruction requests also accept a custom client name.
|
|
16
|
+
- Add a read-only Granola workspace API provider for meeting notes, details, paginated transcripts, folders and spaces, with encrypted shared credentials and the standard operation search/details/read surface.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Add a locally reproducible Eve 0.84 workload fixture with independently verified audience-exchange controls and retained server cancellation receipts, and clarify that custom-audience workload tokens are refused. Live validation still requires separate authorization for disposable resources. Verify the standard 2 GiB Fluid resource preset and require confirmed cleanup even when evidence persistence fails.
|
|
21
|
+
- Replace invariant/spec test-title bookkeeping with executable evidence file
|
|
22
|
+
references and the existing stateful behavior journeys. Require installed-package,
|
|
23
|
+
generated-deployment and Docker smoke in the shared CI build job, with browser,
|
|
24
|
+
audit and real Eve compatibility on every PR. Remove the path classifier,
|
|
25
|
+
duplicate package workflow and completed migration's cross-commit exemption guard.
|
|
26
|
+
Keep focused runtime probes, provider contracts, build/declaration checks and the
|
|
27
|
+
single fail-closed `check` status.
|
|
28
|
+
- Remove completed provider-migration exemptions, legacy generator fixtures and
|
|
29
|
+
unused internal list/guide adapters. Consolidate contributor provider and release
|
|
30
|
+
workflows around the current standard without changing public tools or guides.
|
|
31
|
+
- Replace lower-layer operator, configuration and storage test mirrors with real browser, SQLite restart and deployed Node/Workers persistence journeys. Preserve focused SQL rollback, expiry, isolation, secrecy and cancellation probes.
|
|
32
|
+
- Replace redundant auth, write, privacy and runtime test matrices with real HTTP/MCP identity, one-send recovery, secrecy and socket-cancellation journeys. Require prompt downstream closure and admission release after client cancellation, before the ordinary request deadline. Retain focused security and lifetime fault probes without changing runtime behavior or public APIs.
|
|
33
|
+
- Clarify why QuickBooks invoice updates remain unavailable: sparse field preservation and request IDs do not establish email suppression for existing send-ready invoices. Record the authoritative contract gaps and a proposed unsent-only update for further review; no accounting write surface is added.
|
|
34
|
+
- Replace the remaining catalog-wide provider failure sweep and redundant
|
|
35
|
+
provider/HTTP agreement checks with shared stateful outcomes and real HTTP
|
|
36
|
+
failure/recovery journeys. Retain vendor-specific privacy, MIME, ownership,
|
|
37
|
+
revision, physical-send and lifetime fault probes; remove five internal paging
|
|
38
|
+
helpers used only by the retired tests and private exports hidden by the old
|
|
39
|
+
source scan. Require successful, complete delivered reconciliation data for the
|
|
40
|
+
Tithe.ly cancellation journey; failed, empty, malformed and incomplete replies
|
|
41
|
+
cannot establish recovery. Public provider behavior is unchanged.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- Use permanently unreachable loopback port zero for native transport-failure probes so later test servers cannot answer them. Verify each platform's native connection error while preserving Node/Workers write-outcome, retry, reset and abort assertions.
|
|
46
|
+
|
|
5
47
|
## 0.36.0 — 2026-10-27
|
|
6
48
|
|
|
7
49
|
Connecta 0.36.0 adds a maintained QuickBooks Online Accounting provider for one fixed sandbox or production company per connector. It offers company identity, bounded record searches, individual records and hierarchical financial reports, with contact details and payment credentials withheld. Deployments can opt into customer creation, sparse customer naming updates and unsent US service-invoice creation by setting `readOnly: false` on the connector. Writes need a stable request ID and are never replayed automatically; the API hatch stays read-only, and invoice updates remain unavailable. See [QuickBooks Online](https://github.com/zackbart/connecta/blob/v0.36.0/documentation/providers.md#quickbooks-online).
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Connecta supplies the service credentials, so the agent doesn't need them.
|
|
|
24
24
|
|
|
25
25
|
## What you can connect
|
|
26
26
|
|
|
27
|
-
Maintained connections: <!-- providers:start -->Breeze ChMS, Church Community Builder, Cloudflare, GitHub, Gmail, Google Docs, Google Drive, Google Forms, Google Sheets, Google Slides, Overflow, Planning Center, QuickBooks Online, Stripe, Tithe.ly, and Vercel<!-- providers:end -->.
|
|
27
|
+
Maintained connections: <!-- providers:start -->Breeze ChMS, Church Community Builder, Cloudflare, GitHub, Gmail, Google Docs, Google Drive, Google Forms, Google Sheets, Google Slides, Granola, Overflow, Planning Center, QuickBooks Online, Stripe, Tithe.ly, and Vercel<!-- providers:end -->.
|
|
28
28
|
|
|
29
29
|
Maintained API providers pair reviewed vendor contracts with tools built by
|
|
30
30
|
Connecta. See the [API sources and tool inventory](./documentation/providers.md#api-sources-and-tool-inventory)
|
package/bin/version.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Internal opt-in adapters for the standard hatch.
|
|
2
|
-
//
|
|
1
|
+
// Internal opt-in adapters for the standard hatch. Inputs have crossed
|
|
2
|
+
// connection safety.
|
|
3
3
|
import { ConnectorCallError } from "../errors.js";
|
|
4
4
|
function record(value) {
|
|
5
5
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
package/dist/http/paging.js
CHANGED
|
@@ -1,29 +1,17 @@
|
|
|
1
1
|
// Provider kit (decision 0010, B.4). Unreachable from src/index.ts.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
// Stream for programs and the hatch. The codec is the only place a cursor is
|
|
7
|
-
// minted: `plain` hands the vendor's cursor through; `bound` ties it to the tool and its
|
|
8
|
-
// arguments so a cursor from another call is refused instead of silently
|
|
9
|
-
// reading the wrong list.
|
|
10
|
-
import { Effect, Option, Schema, Stream } from "effect";
|
|
3
|
+
// List tools take one page at a time. Lookahead detects an extra row; bound
|
|
4
|
+
// cursors authenticate the tool, arguments, connection and credential owner.
|
|
5
|
+
import { Effect, Schema } from "effect";
|
|
11
6
|
import { ConnectorCallError } from "../errors.js";
|
|
12
7
|
import { Refusal } from "./errors.js";
|
|
13
|
-
import { confinementBase } from "../http-policy.js";
|
|
14
8
|
import { canonicalJson } from "../canonical-json.js";
|
|
15
9
|
import { ContinuationBinding, ContinuationStorage } from "../runtime/call.js";
|
|
16
10
|
import { continuationKeys } from "../storage/keys.js";
|
|
17
|
-
/** The `page` object every list tool returns. */
|
|
18
|
-
export const PageInfo = Schema.Struct({
|
|
19
|
-
hasMore: Schema.Boolean,
|
|
20
|
-
nextCursor: Schema.NullOr(Schema.String),
|
|
21
|
-
});
|
|
22
11
|
/**
|
|
23
12
|
* The standard `page` (decision 0011 S2): `nextCursor` is present exactly
|
|
24
13
|
* when `hasMore` is true and continuation is safe. A hatch adapter can report
|
|
25
14
|
* more data without a cursor when its continuation cannot be confined.
|
|
26
|
-
* Step-1 lists keep `PageInfo` until their step 2.
|
|
27
15
|
*/
|
|
28
16
|
export const StandardPage = Schema.Struct({
|
|
29
17
|
hasMore: Schema.Boolean,
|
|
@@ -55,79 +43,6 @@ export function lookahead(options) {
|
|
|
55
43
|
return { rows, page: { hasMore, nextCursor: id } };
|
|
56
44
|
});
|
|
57
45
|
}
|
|
58
|
-
/** Vendor-supplied continuation, read only after the connection made its data safe. */
|
|
59
|
-
export function token(options) {
|
|
60
|
-
return Effect.flatMap(Effect.suspend(options.fetch), ({ rows, hasMore, nextCursor }) => {
|
|
61
|
-
if (hasMore && !nextCursor)
|
|
62
|
-
return badPage("The vendor reported another page without a next cursor.");
|
|
63
|
-
return Effect.succeed({ rows, page: { hasMore, nextCursor: hasMore ? nextCursor : null } });
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
/** Offset paging advances by the rows actually returned, with a total when supplied. */
|
|
67
|
-
export function offset(options) {
|
|
68
|
-
return Effect.gen(function* () {
|
|
69
|
-
yield* position(options.offset, 0);
|
|
70
|
-
yield* position(options.limit, 1);
|
|
71
|
-
return yield* Effect.map(options.fetch(options.offset, options.limit), ({ rows, total }) => {
|
|
72
|
-
const next = options.offset + rows.length;
|
|
73
|
-
const hasMore = rows.length > 0 && (total === undefined ? rows.length >= options.limit : next < total);
|
|
74
|
-
return { rows, page: { hasMore, nextCursor: hasMore ? String(next) : null } };
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
/** Page numbers from a vendor's safe body or headers, with full-page fallback. */
|
|
79
|
-
export function page(options) {
|
|
80
|
-
return Effect.gen(function* () {
|
|
81
|
-
yield* position(options.page, 1);
|
|
82
|
-
yield* position(options.limit, 1);
|
|
83
|
-
return yield* Effect.map(options.fetch(options.page, options.limit), ({ rows, hasMore, totalPages }) => {
|
|
84
|
-
const more = hasMore ?? (totalPages === undefined ? rows.length >= options.limit : options.page < totalPages);
|
|
85
|
-
return { rows, page: { hasMore: more, nextCursor: more ? String(options.page + 1) : null } };
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
/** Extract only a cursor from a next URL confined to this connection's origin and path. */
|
|
90
|
-
export function link(options) {
|
|
91
|
-
const base = confinementBase("Paging", options.baseUrl);
|
|
92
|
-
const prefix = base.pathname.replace(/\/+$/, "");
|
|
93
|
-
if (!options.param)
|
|
94
|
-
throw new Error("Link paging needs a cursor parameter.");
|
|
95
|
-
return Effect.flatMap(Effect.suspend(options.fetch), ({ rows, next }) => {
|
|
96
|
-
if (!next)
|
|
97
|
-
return Effect.succeed({ rows, page: { hasMore: false, nextCursor: null } });
|
|
98
|
-
let url;
|
|
99
|
-
try {
|
|
100
|
-
url = new URL(next, base);
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
return badPage("The vendor returned an invalid next-page URL.");
|
|
104
|
-
}
|
|
105
|
-
if (url.origin !== base.origin ||
|
|
106
|
-
!`${url.pathname}/`.startsWith(`${prefix}/`) ||
|
|
107
|
-
url.username ||
|
|
108
|
-
url.password ||
|
|
109
|
-
url.hash)
|
|
110
|
-
return badPage("The vendor returned a next-page URL outside this connection.");
|
|
111
|
-
const cursors = url.searchParams.getAll(options.param);
|
|
112
|
-
if (cursors.length !== 1 || !cursors[0])
|
|
113
|
-
return badPage("The vendor returned no unique next-page cursor.");
|
|
114
|
-
return Effect.succeed({ rows, page: { hasMore: true, nextCursor: cursors[0] } });
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
function position(value, minimum) {
|
|
118
|
-
return Number.isSafeInteger(value) && value >= minimum
|
|
119
|
-
? Effect.void
|
|
120
|
-
: Effect.fail(new Refusal({
|
|
121
|
-
error: new ConnectorCallError("invalid_args", "Paging positions and limits must be whole numbers within their bounds."),
|
|
122
|
-
}));
|
|
123
|
-
}
|
|
124
|
-
function badPage(message) {
|
|
125
|
-
return Effect.fail(new Refusal({ error: new ConnectorCallError("connector_call_failed", message, { retryable: false }) }));
|
|
126
|
-
}
|
|
127
|
-
/** Every row of every page, fetched as the stream is pulled. */
|
|
128
|
-
export function allRows(fetchPage) {
|
|
129
|
-
return Stream.paginate(undefined, (cursor) => Effect.map(fetchPage(cursor), ({ rows, page }) => [rows, Option.fromNullishOr(page.hasMore ? page.nextCursor : null)]));
|
|
130
|
-
}
|
|
131
46
|
const STRAY_CURSOR = "This cursor belongs to a different call. Pass back the nextCursor from the previous page of this same call, with the same arguments.";
|
|
132
47
|
/** Resolved, nonextractable keys only: no request promise is retained between calls. */
|
|
133
48
|
const signingKeys = new WeakMap();
|
package/dist/http/provider.js
CHANGED
|
@@ -191,8 +191,7 @@ export function defineApiProvider(definition) {
|
|
|
191
191
|
// The declared fact the Writes convention states must match what the write builders send.
|
|
192
192
|
if (idempotencyKey !== undefined && [...builtTools, ...hatchTools].some(takesIdempotencyKey) !== idempotencyKey)
|
|
193
193
|
throw new Error(`${metadata.title} declares idempotencyKey: ${idempotencyKey}, but its write tools ${idempotencyKey ? "send no" : "send an"} idempotency key.`);
|
|
194
|
-
const
|
|
195
|
-
const renderedGuide = "content" in usageGuide ? usageGuide : provider.usageGuide(usageGuide);
|
|
194
|
+
const renderedGuide = provider.usageGuide(guide(options));
|
|
196
195
|
const connector = tracksEveryRequest(api(id, {
|
|
197
196
|
...(defaultCallAdmission
|
|
198
197
|
? {
|
package/dist/http/tools.js
CHANGED
|
@@ -22,7 +22,7 @@ import { causeError, runEdge } from "../runtime/run.js";
|
|
|
22
22
|
import { recordUnauthorized, Refusal, RequestRefused, toConnectorCallError, withheldDefect, } from "./errors.js";
|
|
23
23
|
import { fieldsInput, fieldsProblem, selectFields } from "./fields.js";
|
|
24
24
|
import { catalogSchema, Integer } from "./json-schema.js";
|
|
25
|
-
import { boundCursor, Cursor,
|
|
25
|
+
import { boundCursor, Cursor, StandardPage } from "./paging.js";
|
|
26
26
|
import { Classification, Dispatches, IdempotencyKey, Recovery, ReadTool, WriteCapacity, writeRequestKey, fromConnectorContext, } from "./services.js";
|
|
27
27
|
const inputs = new WeakMap();
|
|
28
28
|
const keyed = new WeakSet();
|
|
@@ -118,28 +118,6 @@ export function get(connection, spec) {
|
|
|
118
118
|
export function list(connection, spec) {
|
|
119
119
|
if (spec.cursor !== undefined && spec.cursor !== "plain" && spec.cursor !== "bound")
|
|
120
120
|
throw new Error('A list cursor must be "plain" or "bound".');
|
|
121
|
-
if (!("key" in spec))
|
|
122
|
-
return standardList(connection, spec);
|
|
123
|
-
if (!spec.key || spec.key === "page")
|
|
124
|
-
throw new Error("A list needs a row key distinct from page.");
|
|
125
|
-
return read(connection, {
|
|
126
|
-
...spec,
|
|
127
|
-
output: Schema.Struct({ [spec.key]: Schema.Array(spec.row), page: PageInfo }),
|
|
128
|
-
run: (args) => Effect.gen(function* () {
|
|
129
|
-
const fields = args;
|
|
130
|
-
const supplied = typeof fields.cursor === "string" ? fields.cursor : undefined;
|
|
131
|
-
const cursor = supplied !== undefined && spec.cursor === "bound"
|
|
132
|
-
? yield* boundCursor.decode(spec.name, fields, supplied)
|
|
133
|
-
: supplied;
|
|
134
|
-
const result = yield* spec.page(args, cursor);
|
|
135
|
-
const nextCursor = result.page.nextCursor !== null && spec.cursor === "bound"
|
|
136
|
-
? yield* boundCursor.encode(spec.name, fields, result.page.nextCursor)
|
|
137
|
-
: result.page.nextCursor;
|
|
138
|
-
return { [spec.key]: result.rows, page: { ...result.page, nextCursor } };
|
|
139
|
-
}),
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
function standardList(connection, spec) {
|
|
143
121
|
const { default: size, max } = spec.limit;
|
|
144
122
|
if (spec.total !== undefined && spec.total !== true)
|
|
145
123
|
throw new Error(`${spec.name} total must opt in with true for a vendor-stated exact count.`);
|
package/dist/program-writes.js
CHANGED
|
@@ -4,7 +4,6 @@ import { CONFIG_DEFAULTS } from "./config-defaults.js";
|
|
|
4
4
|
import { Effect } from "effect";
|
|
5
5
|
import { jsonResult } from "./meta-tools.js";
|
|
6
6
|
import { classifyWriteOutcome } from "./write-outcome.js";
|
|
7
|
-
export { classifyWriteOutcome } from "./write-outcome.js";
|
|
8
7
|
/** Writes one program may send (`execute.maxWrites`), unless configured. */
|
|
9
8
|
export const DEFAULT_MAX_WRITES = CONFIG_DEFAULTS.execute.maxWrites;
|
|
10
9
|
/** What a dispatched write's outcome says about whether it landed. */
|
|
@@ -1,22 +1,6 @@
|
|
|
1
1
|
// A provider's sensitive fields leave every result unless deployment code
|
|
2
2
|
// returns them; no tool argument can. Shared by the providers whose raw rows
|
|
3
3
|
// and hatches would otherwise reach what their projections drop.
|
|
4
|
-
/** What `raw: true` and the hatches return, under the deployment's `sensitiveFields` choice. */
|
|
5
|
-
export function unprojectedResults(sensitiveFields, names) {
|
|
6
|
-
if (sensitiveFields === "return")
|
|
7
|
-
return (body) => body;
|
|
8
|
-
const dropped = new Set(names);
|
|
9
|
-
const strip = (value) => {
|
|
10
|
-
if (Array.isArray(value))
|
|
11
|
-
return value.map(strip);
|
|
12
|
-
if (!value || typeof value !== "object")
|
|
13
|
-
return value;
|
|
14
|
-
return Object.fromEntries(Object.entries(value)
|
|
15
|
-
.filter(([name]) => !dropped.has(name))
|
|
16
|
-
.map(([name, entry]) => [name, strip(entry)]));
|
|
17
|
-
};
|
|
18
|
-
return (body) => strip(body);
|
|
19
|
-
}
|
|
20
4
|
/** Refuse anything but the two choices, at construction (INV-11). */
|
|
21
5
|
export function sensitiveFieldsOption(provider, value) {
|
|
22
6
|
if (value === undefined)
|
|
@@ -66,7 +66,7 @@ const PATH_PATTERN = /^(\/[a-z0-9_]+)+\/?$/;
|
|
|
66
66
|
* which reads the calendar without a key, and its embed key. Neither hatch
|
|
67
67
|
* returns them.
|
|
68
68
|
*/
|
|
69
|
-
|
|
69
|
+
function withoutCalendarFeeds(path, body) {
|
|
70
70
|
if (path !== "/events/calendars/list")
|
|
71
71
|
return body;
|
|
72
72
|
const strip = (value) => {
|
|
@@ -80,7 +80,7 @@ export function withoutCalendarFeeds(path, body) {
|
|
|
80
80
|
};
|
|
81
81
|
return strip(body);
|
|
82
82
|
}
|
|
83
|
-
|
|
83
|
+
function normalizedPath(raw) {
|
|
84
84
|
const path = typeof raw === "string" ? raw : "";
|
|
85
85
|
if (!PATH_PATTERN.test(path)) {
|
|
86
86
|
throw new ConnectorCallError("invalid_args", "A Breeze path is relative to /api, begins with '/', and uses only lowercase letters, digits, underscores, and '/' — for example /tags/list_folders. No query string, host, or dot segments.");
|
|
@@ -238,7 +238,7 @@ export const CCB_INDEX = new OperationIndex({
|
|
|
238
238
|
export const getMe = get("getMe", "/me", { success: Schema.Unknown });
|
|
239
239
|
export const CcbApi = indexedVendor("CCB", CCB_INDEX, [getMe]);
|
|
240
240
|
const safety = valueSafety(CCB_VALUE_SAFETY, () => CCB_INDEX);
|
|
241
|
-
|
|
241
|
+
const CCB_PAGE_HEADERS = ["x-page", "x-next-page", "x-total-pages", "x-total"];
|
|
242
242
|
export function ccbPageInfo(headers, page, size, rows) {
|
|
243
243
|
const number = (name) => {
|
|
244
244
|
const raw = headers[name];
|
|
@@ -157,7 +157,7 @@ export const UPLOADS = [
|
|
|
157
157
|
* pin, zone creation must name a pinned account, and the rest is public data.
|
|
158
158
|
* Every other unscoped operation is refused under a pin (default deny).
|
|
159
159
|
*/
|
|
160
|
-
|
|
160
|
+
const PIN_SAFE_UNSCOPED = new Set([
|
|
161
161
|
"GET /user",
|
|
162
162
|
"GET /user/tokens/verify",
|
|
163
163
|
"GET /accounts",
|
|
@@ -177,7 +177,7 @@ export const PIN_SAFE_UNSCOPED = new Set([
|
|
|
177
177
|
"POST /graphql",
|
|
178
178
|
]);
|
|
179
179
|
/** Why each family of unscoped operations is refused under a pin; `test` checks every one is covered. */
|
|
180
|
-
|
|
180
|
+
const PIN_REFUSED_UNSCOPED = {
|
|
181
181
|
accounts: "creates or moves accounts outside the pin",
|
|
182
182
|
analytics: "queries datasets across every account; use POST /accounts/{account_tag}/analytics/sql",
|
|
183
183
|
api: "is an internal health route",
|
|
@@ -445,18 +445,18 @@ export const cloudflareIndex = new CloudflareIndex(cloudflareDiscovery(openapi),
|
|
|
445
445
|
title: "Cloudflare",
|
|
446
446
|
slashParams: SLASH_PARAMS,
|
|
447
447
|
});
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
448
|
+
const getUser = get("getUser", "/user", { success: Schema.Unknown });
|
|
449
|
+
const verifyToken = get("verifyToken", "/user/tokens/verify", { success: Schema.Unknown });
|
|
450
|
+
const verifyAccountToken = get("verifyAccountToken", "/accounts/:account_id/tokens/verify", {
|
|
451
451
|
params: { account_id: Schema.String },
|
|
452
452
|
success: Schema.Unknown,
|
|
453
453
|
});
|
|
454
454
|
const PageQuery = { page: Schema.optionalKey(Integer), per_page: Schema.optionalKey(Integer) };
|
|
455
|
-
|
|
455
|
+
const listAccounts = get("listAccounts", "/accounts", {
|
|
456
456
|
query: { ...PageQuery, name: Schema.optionalKey(Schema.String) },
|
|
457
457
|
success: Schema.Unknown,
|
|
458
458
|
});
|
|
459
|
-
|
|
459
|
+
const listZones = get("listZones", "/zones", {
|
|
460
460
|
query: {
|
|
461
461
|
...PageQuery,
|
|
462
462
|
name: Schema.optionalKey(Schema.String),
|
|
@@ -465,7 +465,7 @@ export const listZones = get("listZones", "/zones", {
|
|
|
465
465
|
},
|
|
466
466
|
success: Schema.Unknown,
|
|
467
467
|
});
|
|
468
|
-
|
|
468
|
+
const graphqlQuery = post("graphqlQuery", "/graphql", {
|
|
469
469
|
payload: Schema.Struct({
|
|
470
470
|
query: Schema.String,
|
|
471
471
|
variables: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Cloudflare's v4 REST base. Override only for a proxy or a test double. */
|
|
2
2
|
export declare const CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
3
3
|
/** The two key schemes Cloudflare's v4 API accepts. */
|
|
4
|
-
|
|
4
|
+
type CloudflareKeyAuth = "apiToken" | "globalApiKey";
|
|
5
5
|
/** Accounts and zones a connector may touch; everything else is refused. */
|
|
6
6
|
export interface CloudflarePin {
|
|
7
7
|
accountIds?: readonly string[];
|
|
@@ -14,3 +14,4 @@ export interface CloudflareRestOptions {
|
|
|
14
14
|
zoneId: string | undefined;
|
|
15
15
|
pin: CloudflarePin | undefined;
|
|
16
16
|
}
|
|
17
|
+
export {};
|
|
@@ -12,7 +12,7 @@ export const DOCS = {
|
|
|
12
12
|
notFound: "ambiguous",
|
|
13
13
|
};
|
|
14
14
|
const DocumentId = { documentId: Schema.String };
|
|
15
|
-
|
|
15
|
+
const getDocument = get("getDocument", "/documents/:documentId", {
|
|
16
16
|
params: DocumentId,
|
|
17
17
|
query: {
|
|
18
18
|
includeTabsContent: Schema.optionalKey(Schema.String),
|
|
@@ -18,7 +18,7 @@ export const DRIVE = {
|
|
|
18
18
|
};
|
|
19
19
|
const FileId = { fileId: Schema.String };
|
|
20
20
|
const PermissionId = { ...FileId, permissionId: Schema.String };
|
|
21
|
-
|
|
21
|
+
const getFile = get("getFile", "/files/:fileId", { params: FileId, success: Schema.Unknown });
|
|
22
22
|
const OPERATIONS = [
|
|
23
23
|
get("listFiles", "/files", { success: Schema.Unknown }),
|
|
24
24
|
post("createFile", "/files", { payload: Schema.Unknown, success: Schema.Unknown }),
|
|
@@ -8,7 +8,7 @@ export const driveIndex = new OperationIndex(openapi, {
|
|
|
8
8
|
title: "Google Drive",
|
|
9
9
|
pathMatching: "uri-template",
|
|
10
10
|
});
|
|
11
|
-
|
|
11
|
+
const DRIVE_REFUSALS = {
|
|
12
12
|
"GET /apps": "This operation has not been admitted to the narrow Drive execution allowlist; use the named file and permission workflows.",
|
|
13
13
|
"GET /apps/{appId}": "This operation has not been admitted to the narrow Drive execution allowlist; use the named file and permission workflows.",
|
|
14
14
|
"GET /changes": "This operation has not been admitted to the narrow Drive execution allowlist; use the named file and permission workflows.",
|
|
@@ -1138,7 +1138,7 @@ function guarded(tool) {
|
|
|
1138
1138
|
}),
|
|
1139
1139
|
};
|
|
1140
1140
|
}
|
|
1141
|
-
|
|
1141
|
+
function buildTool(connection, tool) {
|
|
1142
1142
|
const isRead = tool.annotations.readOnlyHint;
|
|
1143
1143
|
const isList = ["search_files", "list_folder_items", "list_permissions", "list_shared_drives"].includes(tool.name);
|
|
1144
1144
|
const output = asRecord(catalogSchema(tool.contract.output, "output"));
|
|
@@ -17,7 +17,7 @@ export const FORMS = {
|
|
|
17
17
|
notFound: "ambiguous",
|
|
18
18
|
};
|
|
19
19
|
const FormId = { formId: Schema.String };
|
|
20
|
-
|
|
20
|
+
const getForm = get("getForm", "/forms/:formId", { params: FormId, success: Schema.Unknown });
|
|
21
21
|
const OPERATIONS = [
|
|
22
22
|
getForm,
|
|
23
23
|
post("createForm", "/forms", {
|
|
@@ -15,7 +15,7 @@ export const FORMS_REQUEST_KINDS = [
|
|
|
15
15
|
"updateFormInfo",
|
|
16
16
|
"updateSettings",
|
|
17
17
|
];
|
|
18
|
-
|
|
18
|
+
const FORMS_REFUSALS = {
|
|
19
19
|
"POST /forms/{formId}:setPublishSettings": "Publishing and response availability changes require separate product authorization; the owner can change them in Forms.",
|
|
20
20
|
"POST /forms/{formId}/watches": "Watch registration creates persistent Pub/Sub delivery and requires separate product authorization.",
|
|
21
21
|
"GET /forms/{formId}/watches": "Watch administration is outside this connection; manage watches through the approved operator workflow.",
|
|
@@ -16,7 +16,7 @@ import { googleBatchGuard } from "../_shared/google/hatch.js";
|
|
|
16
16
|
const MAX_PAGE_SIZE = 100;
|
|
17
17
|
const DEFAULT_PAGE_SIZE = 25;
|
|
18
18
|
/** A listed answer is cut here; get_response returns it whole. */
|
|
19
|
-
|
|
19
|
+
const LIST_ANSWER_CHARS = 2_000;
|
|
20
20
|
/** A form or item title or description is cut here in get_form's projection. */
|
|
21
21
|
const DESCRIPTION_CHARS = 2_000;
|
|
22
22
|
/** ...and bounded here in bytes, so escaped control characters cannot grow it. */
|
|
@@ -13,7 +13,7 @@ import { resultSafety } from "../../http/safety.js";
|
|
|
13
13
|
import { connect, get, operations } from "../../http/vendor.js";
|
|
14
14
|
const repository = { owner: Schema.String, repo: Schema.String };
|
|
15
15
|
const success = Schema.Unknown;
|
|
16
|
-
|
|
16
|
+
const GitHubApi = indexedVendor("GitHub", githubIndex, [
|
|
17
17
|
get("repository", "/repos/:owner/:repo", { params: repository, success }),
|
|
18
18
|
]);
|
|
19
19
|
function rateLimited(status, retry, remaining) {
|
|
@@ -106,7 +106,7 @@ export const GITHUB_VERDICTS = {
|
|
|
106
106
|
[MERGE]: "refuse",
|
|
107
107
|
...Object.fromEntries([...INTERNAL_READS].map((key) => [key, "refuse"])),
|
|
108
108
|
};
|
|
109
|
-
|
|
109
|
+
const GITHUB_NAMED = {
|
|
110
110
|
[MERGE]: "merge_pull_request",
|
|
111
111
|
"GET /installation/repositories": "list_scopes",
|
|
112
112
|
"GET /search/repositories": "search_scoped",
|
|
@@ -114,7 +114,7 @@ export const GITHUB_NAMED = {
|
|
|
114
114
|
"GET /search/code": "search_scoped",
|
|
115
115
|
};
|
|
116
116
|
const adapter = githubPage("https://api.github.com");
|
|
117
|
-
|
|
117
|
+
const GITHUB_PAGES = Object.fromEntries(githubIndex.ops
|
|
118
118
|
.filter((op) => {
|
|
119
119
|
const key = `${op.method} ${op.path}`;
|
|
120
120
|
return (READ_PERMISSIONS[key] && githubIndex.contract(op).parameters.some((p) => p.in === "query" && p.name === "page"));
|
|
@@ -22,7 +22,7 @@ const Page = {
|
|
|
22
22
|
maxResults: Schema.optionalKey(Schema.String),
|
|
23
23
|
pageToken: Schema.optionalKey(Schema.String),
|
|
24
24
|
};
|
|
25
|
-
|
|
25
|
+
const listLabels = get("listLabels", "/labels", { success: Schema.Unknown });
|
|
26
26
|
const OPERATIONS = [
|
|
27
27
|
get("listThreads", "/threads", {
|
|
28
28
|
query: { ...Page, includeSpamTrash: Schema.optionalKey(Schema.String) },
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { ConnectorCallError } from "../../errors.js";
|
|
3
|
+
import { Refusal, StatusFailure } from "../../http/errors.js";
|
|
4
|
+
import { Credentials } from "../../http/services.js";
|
|
5
|
+
import { restSafety } from "../../http/safety.js";
|
|
6
|
+
import { get } from "../../http/vendor.js";
|
|
7
|
+
import { indexedVendor } from "../_shared/rest/kit.js";
|
|
8
|
+
import { OperationIndex } from "../_shared/rest/operation-index.js";
|
|
9
|
+
import { valueSafety } from "../_shared/rest/value-safety.js";
|
|
10
|
+
import { openapi } from "./openapi.generated.js";
|
|
11
|
+
import { GRANOLA_VALUE_SAFETY, READS } from "./safety.js";
|
|
12
|
+
export const index = new OperationIndex(openapi, { vendor: "granola", title: "Granola" });
|
|
13
|
+
export const verdicts = Object.fromEntries(index.ops.map((op) => [
|
|
14
|
+
`${op.method} ${op.path}`,
|
|
15
|
+
READS.has(`${op.method} ${op.path}`) ? "read" : "refuse",
|
|
16
|
+
]));
|
|
17
|
+
const listSpaces = get("listSpaces", "/v1/spaces", {
|
|
18
|
+
query: { page_size: Schema.optionalKey(Schema.Number) },
|
|
19
|
+
success: Schema.Unknown,
|
|
20
|
+
});
|
|
21
|
+
export const GranolaApi = indexedVendor("Granola", index, [listSpaces]);
|
|
22
|
+
const reviewed = valueSafety(GRANOLA_VALUE_SAFETY, () => index);
|
|
23
|
+
export const safety = () => ({
|
|
24
|
+
reviewed: restSafety(reviewed, index),
|
|
25
|
+
sensitiveFields: { names: [], choice: "drop" },
|
|
26
|
+
});
|
|
27
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
28
|
+
const printableToken = (value) => /^[!-~]+$/.test(value);
|
|
29
|
+
const page = {
|
|
30
|
+
param: "cursor",
|
|
31
|
+
in: "query",
|
|
32
|
+
decode(cursor) {
|
|
33
|
+
if (typeof cursor !== "string" ||
|
|
34
|
+
!cursor.trim() ||
|
|
35
|
+
[...cursor].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) ||
|
|
36
|
+
/^[a-z][a-z0-9+.-]*:/i.test(cursor))
|
|
37
|
+
throw new ConnectorCallError("invalid_args", "This cursor contains no valid Granola page token.");
|
|
38
|
+
return cursor;
|
|
39
|
+
},
|
|
40
|
+
next(data) {
|
|
41
|
+
const body = object(data);
|
|
42
|
+
if (body.hasMore === false && (body.cursor === null || body.cursor === undefined))
|
|
43
|
+
return null;
|
|
44
|
+
if (body.hasMore === true && typeof body.cursor === "string" && body.cursor.trim()) {
|
|
45
|
+
try {
|
|
46
|
+
return page.decode(body.cursor);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* Fail closed on malformed vendor metadata. */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new ConnectorCallError("connector_call_failed", "Granola returned inconsistent paging metadata.", {
|
|
53
|
+
retryable: false,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
export const pages = Object.fromEntries([...READS].filter((op) => op !== "GET /v1/notes/{note_id}").map((op) => [op, page]));
|
|
58
|
+
/** The compact index preserves bounds/formats but omits vendor regex patterns. */
|
|
59
|
+
export function binding({ connection }) {
|
|
60
|
+
return {
|
|
61
|
+
review: "Exact pinned note/folder/space identifier patterns before dispatch; standard JSON GET framing through Connection.sendPath.",
|
|
62
|
+
refuse(call) {
|
|
63
|
+
if (call.op.path.includes("{note_id}") && !/^\/v1\/notes\/not_[a-z0-9]{14}(?:\/transcript)?$/i.test(call.path))
|
|
64
|
+
return "Use a discovered Granola not_ API identifier with 14 alphanumeric characters, not a web-link UUID.";
|
|
65
|
+
for (const [key, prefix] of [
|
|
66
|
+
["folder_id", "fol"],
|
|
67
|
+
["space_id", "spa"],
|
|
68
|
+
]) {
|
|
69
|
+
const value = call.query[key];
|
|
70
|
+
if (value !== undefined &&
|
|
71
|
+
(typeof value !== "string" || !new RegExp(`^${prefix}_[a-z0-9]{14}$`, "i").test(value)))
|
|
72
|
+
return `Use a discovered Granola ${prefix}_ API identifier for ${key}.`;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
},
|
|
76
|
+
send: (call, options) => connection.sendPath({
|
|
77
|
+
method: call.method,
|
|
78
|
+
path: call.path,
|
|
79
|
+
template: call.op.path,
|
|
80
|
+
query: Object.entries(call.query).map(([key, value]) => [key, String(value)]),
|
|
81
|
+
}, options),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export const authenticate = Effect.gen(function* () {
|
|
85
|
+
const values = yield* (yield* Credentials).values.pipe(Effect.mapError((error) => new Refusal({ error })));
|
|
86
|
+
const key = values?.value?.trim();
|
|
87
|
+
if (!key || !printableToken(key))
|
|
88
|
+
return yield* Effect.fail(new Refusal({
|
|
89
|
+
error: new ConnectorCallError("auth_required", "An operator must configure a valid Granola workspace API key in this connection's credential slot."),
|
|
90
|
+
}));
|
|
91
|
+
return { Authorization: `Bearer ${key}` };
|
|
92
|
+
});
|
|
93
|
+
export const errors = {
|
|
94
|
+
provider: "Granola",
|
|
95
|
+
facts: () => ({}),
|
|
96
|
+
numericFacts: {
|
|
97
|
+
retryAfterMs: {
|
|
98
|
+
header: "retry-after",
|
|
99
|
+
format: "retry-after",
|
|
100
|
+
review: "HTTP Retry-After delay on rate-limited responses.",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
meanings: { unknownId: "connector_call_failed", provesAbsence: false, conflicts: [] },
|
|
104
|
+
detail: ({ status }) => `Granola returned HTTP ${status}.`,
|
|
105
|
+
unreadable: "Granola returned an invalid JSON success body.",
|
|
106
|
+
statuses: {
|
|
107
|
+
401: () => ({
|
|
108
|
+
code: "auth_required",
|
|
109
|
+
words: (detail) => `${detail} The workspace API key is invalid or revoked; an operator must replace it.`,
|
|
110
|
+
}),
|
|
111
|
+
403: () => ({
|
|
112
|
+
code: "provider_permission_denied",
|
|
113
|
+
words: (detail) => `${detail} Check the workspace key's access and the space's Granola API integration.`,
|
|
114
|
+
}),
|
|
115
|
+
404: () => ({
|
|
116
|
+
code: "connector_call_failed",
|
|
117
|
+
words: (detail) => `${detail} The note is inaccessible, missing, or has no generated summary/transcript; this does not prove absence.`,
|
|
118
|
+
}),
|
|
119
|
+
413: () => ({
|
|
120
|
+
code: "connector_call_failed",
|
|
121
|
+
words: (detail) => `${detail} If include=transcript was requested, read /v1/notes/{note_id}/transcript in pages, then read note details without include. No automatic fallback or extra request was sent.`,
|
|
122
|
+
}),
|
|
123
|
+
429: ({ retryAfterMs }) => ({
|
|
124
|
+
code: "rate_limited",
|
|
125
|
+
retryAfterMs: retryAfterMs ?? 5_000,
|
|
126
|
+
words: (detail) => `${detail} The workspace shares a burst of 25 requests per 5 seconds and a sustained 5 requests/second. Wait before retrying.`,
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
/** Validate framing, independently of optional response fields and the caller's field selection. */
|
|
131
|
+
export const result = (body, answer) => {
|
|
132
|
+
const root = object(body);
|
|
133
|
+
const path = answer.operation.template;
|
|
134
|
+
const key = path === "/v1/notes"
|
|
135
|
+
? "notes"
|
|
136
|
+
: path === "/v1/folders"
|
|
137
|
+
? "folders"
|
|
138
|
+
: path === "/v1/spaces"
|
|
139
|
+
? "spaces"
|
|
140
|
+
: path.endsWith("/transcript")
|
|
141
|
+
? "transcript"
|
|
142
|
+
: undefined;
|
|
143
|
+
const valid = key
|
|
144
|
+
? Array.isArray(root[key]) && typeof root.hasMore === "boolean"
|
|
145
|
+
: typeof root.id === "string" && /^not_[a-zA-Z0-9]{14}$/.test(root.id);
|
|
146
|
+
return valid
|
|
147
|
+
? Effect.succeed(body)
|
|
148
|
+
: Effect.fail(new StatusFailure({ status: answer.status, headers: answer.headers, body: {}, operation: answer.operation }));
|
|
149
|
+
};
|
|
150
|
+
export const probe = {
|
|
151
|
+
operation: listSpaces,
|
|
152
|
+
request: { query: { page_size: 1 } },
|
|
153
|
+
check: (values) => !values.value?.trim() || !printableToken(values.value.trim()) ? "Enter a Granola workspace API key." : undefined,
|
|
154
|
+
describe: () => "Authenticated to the accessible Granola spaces. This does not prove access to every workspace note or verify the key's ownership type.",
|
|
155
|
+
};
|