@zackbart/connecta 0.36.0 → 0.36.2
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 +55 -0
- package/README.md +1 -1
- package/bin/version.mjs +1 -1
- package/dist/auth/downstream-oauth.js +3 -0
- package/dist/auth/oauth-authorization-params.js +52 -0
- package/dist/auth/static-oauth.js +1 -4
- package/dist/connectors/option-shapes.js +1 -0
- package/dist/connectors/remote-mcp.d.ts +4 -1
- package/dist/connectors/remote-mcp.js +12 -1
- package/dist/google.d.ts +13 -0
- package/dist/google.js +42 -0
- 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/google/service-account.d.ts +1 -1
- package/dist/providers/_shared/google/service-account.js +20 -8
- 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/request-token.d.ts +7 -0
- package/dist/request-token.js +1 -0
- 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/README.md +13 -12
- package/documentation/architecture.md +148 -76
- package/documentation/auth.md +198 -4
- 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 +10 -2
- package/templates/node/package.json +1 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** One workspace-owned Granola REST API key, managed in the encrypted vault. */
|
|
2
|
+
export interface GranolaOptions {
|
|
3
|
+
purpose: string;
|
|
4
|
+
title?: string;
|
|
5
|
+
instructions?: string;
|
|
6
|
+
maxResultBytes?: number;
|
|
7
|
+
/** Common connector restriction; no value enables unreviewed operations or writes. */
|
|
8
|
+
readOnly?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare const granola: import("../../provider.js").ProviderFactory<GranolaOptions>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { keys, optionsOf } from "../../config-schema.js";
|
|
2
|
+
import { defineApiProvider } from "../../http/provider.js";
|
|
3
|
+
import { GranolaApi, authenticate, binding, errors, index, pages, probe, result, safety, verdicts } from "./api.js";
|
|
4
|
+
import { granolaTools } from "./tools.js";
|
|
5
|
+
import { skill } from "./skill.generated.js";
|
|
6
|
+
export const granola = defineApiProvider({
|
|
7
|
+
name: "granola",
|
|
8
|
+
title: "Granola",
|
|
9
|
+
readme: "Granola",
|
|
10
|
+
options: optionsOf()(keys("purpose", "title", "instructions", "maxResultBytes", "readOnly")),
|
|
11
|
+
skill,
|
|
12
|
+
bundle: {
|
|
13
|
+
baselineGzip: 360040,
|
|
14
|
+
note: "./providers/granola standalone measured 360,040 B gzip with Homebrew Node 22 on 2026-10-27. This includes the shared Effect provider kit, paid once per deployment; provider entries are reported, not capped, and standalone sizes are not additive.",
|
|
15
|
+
},
|
|
16
|
+
vendor: GranolaApi,
|
|
17
|
+
baseUrl: () => "https://public-api.granola.ai",
|
|
18
|
+
description: (options) => `Granola workspace meeting notes: ${options.purpose}`,
|
|
19
|
+
authScope: () => "shared",
|
|
20
|
+
maxResponseBytes: 4 * 1024 * 1024,
|
|
21
|
+
timeoutMs: 30_000,
|
|
22
|
+
headers: { Accept: "application/json" },
|
|
23
|
+
idempotencyKey: false,
|
|
24
|
+
defaultCallAdmission: {
|
|
25
|
+
rules: [
|
|
26
|
+
{
|
|
27
|
+
maxConcurrency: 2,
|
|
28
|
+
maxQueueSize: 25,
|
|
29
|
+
queueTimeoutMs: 5_000,
|
|
30
|
+
retryAfterMs: 1_000,
|
|
31
|
+
budget: { kind: "rolling-window", maxCalls: 25, windowMs: 5_000 },
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
credential: {
|
|
36
|
+
label: "Granola workspace API key",
|
|
37
|
+
description: "Workspace-owned key from Settings → Connectors → Workspace API keys (Business or Enterprise admin). Public notes and API-enabled spaces only; not a personal key or MCP OAuth token. Stored encrypted and sent only to public-api.granola.ai.",
|
|
38
|
+
placeholder: "grn_…",
|
|
39
|
+
},
|
|
40
|
+
authenticate,
|
|
41
|
+
errors,
|
|
42
|
+
safety,
|
|
43
|
+
result,
|
|
44
|
+
probe,
|
|
45
|
+
guide: (options) => ({
|
|
46
|
+
heading: "Granola usage",
|
|
47
|
+
summary: "Read workspace notes and transcripts; discover accessible spaces and folders before filtering.",
|
|
48
|
+
context: [
|
|
49
|
+
`Workspace purpose: ${options.purpose}`,
|
|
50
|
+
"One shared workspace API key over REST. This connection exposes only reviewed reads.",
|
|
51
|
+
],
|
|
52
|
+
required: true,
|
|
53
|
+
}),
|
|
54
|
+
hatch: {
|
|
55
|
+
name: "granola",
|
|
56
|
+
title: "Granola",
|
|
57
|
+
index,
|
|
58
|
+
verdicts,
|
|
59
|
+
pages,
|
|
60
|
+
pageCursor: "argument",
|
|
61
|
+
binding,
|
|
62
|
+
pathExample: "/v1/notes",
|
|
63
|
+
notes: () => ({
|
|
64
|
+
read: "Omit query.cursor initially; pass page.nextCursor unchanged as top-level cursor with the same path and query. Note/folder/space page_size defaults to 10 (maximum 30); transcripts default to 50 (maximum 100).",
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
tools: granolaTools,
|
|
68
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { compactOpenApi } from "../_shared/rest/compact-index.js";
|
|
2
|
+
export const openapi = compactOpenApi({
|
|
3
|
+
source: "https://docs.granola.ai/api-reference/openapi.json",
|
|
4
|
+
revision: "sha256:3f1a93ae6380",
|
|
5
|
+
digest: "sha256:3f1a93ae6380feb5297df8115855486224f82bea236eaf1fd916072843f5759d",
|
|
6
|
+
version: "1.0.0",
|
|
7
|
+
servers: ["https://public-api.granola.ai"],
|
|
8
|
+
tags: ["audit", "folders", "legal-holds", "notes", "spaces", "webhook-endpoints"],
|
|
9
|
+
ops: [
|
|
10
|
+
["GET", "/v1/audit", "get/v1/audit", "List audit events", 0],
|
|
11
|
+
["GET", "/v1/folders", "get/v1/folders", "List folders", 1],
|
|
12
|
+
["GET", "/v1/legal-holds", "get/v1/legal-holds", "List legal holds", 2],
|
|
13
|
+
["POST", "/v1/legal-holds", "post/v1/legal-holds", "Create legal hold", 2],
|
|
14
|
+
["GET", "/v1/legal-holds/{hold_id}", "get/v1/legal-holds/{hold_id}", "Get legal hold", 2],
|
|
15
|
+
["PATCH", "/v1/legal-holds/{hold_id}", "patch/v1/legal-holds/{hold_id}", "Update legal hold", 2],
|
|
16
|
+
["DELETE", "/v1/legal-holds/{hold_id}", "delete/v1/legal-holds/{hold_id}", "Release legal hold", 2],
|
|
17
|
+
["GET", "/v1/legal-holds/{hold_id}/custodians", "get/v1/legal-holds/{hold_id}/custodians", "List legal hold custodians", 2],
|
|
18
|
+
["POST", "/v1/legal-holds/{hold_id}/custodians", "post/v1/legal-holds/{hold_id}/custodians", "Add legal hold custodians", 2],
|
|
19
|
+
["DELETE", "/v1/legal-holds/{hold_id}/custodians/{custodian_id}", "delete/v1/legal-holds/{hold_id}/custodians/{custodian_id}", "Remove legal hold custodian", 2],
|
|
20
|
+
["GET", "/v1/notes", "get/v1/notes", "List notes", 3],
|
|
21
|
+
["GET", "/v1/notes/{note_id}", "get/v1/notes/{note_id}", "Get note", 3],
|
|
22
|
+
["GET", "/v1/notes/{note_id}/transcript", "get/v1/notes/{note_id}/transcript", "Get transcript", 3],
|
|
23
|
+
["GET", "/v1/spaces", "get/v1/spaces", "List spaces", 4],
|
|
24
|
+
["GET", "/v1/webhook-endpoints", "get/v1/webhook-endpoints", "List webhook endpoints", 5],
|
|
25
|
+
["POST", "/v1/webhook-endpoints", "post/v1/webhook-endpoints", "Create webhook endpoint", 5],
|
|
26
|
+
["PATCH", "/v1/webhook-endpoints/{webhook_endpoint_id}", "patch/v1/webhook-endpoints/{webhook_endpoint_id}", "Update webhook endpoint", 5],
|
|
27
|
+
["DELETE", "/v1/webhook-endpoints/{webhook_endpoint_id}", "delete/v1/webhook-endpoints/{webhook_endpoint_id}", "Delete webhook endpoint", 5],
|
|
28
|
+
],
|
|
29
|
+
fields: "{\"p\":[\"attachments\",\"attachments[].height\",\"attachments[].id\",\"attachments[].object\",\"attachments[].type\",\"attachments[].url\",\"attachments[].width\",\"attendees\",\"attendees[].email\",\"attendees[].name\",\"calendar_event\",\"covers_entire_workspace\",\"created_at\",\"created_by\",\"cursor\",\"custodian_count\",\"custodians\",\"custodians[].added_at\",\"custodians[].id\",\"custodians[].object\",\"custodians[].removed_at\",\"custodians[].user\",\"deleted\",\"deleted_at\",\"description\",\"enabled\",\"events\",\"events[].action\",\"events[].actor\",\"events[].collected_at\",\"events[].context\",\"events[].data\",\"events[].id\",\"events[].object\",\"events[].occurred_at\",\"folder_ids\",\"folder_membership\",\"folder_membership[].id\",\"folder_membership[].name\",\"folder_membership[].object\",\"folder_membership[].parent_folder_id\",\"folder_membership[].space_id\",\"folders\",\"folders[].id\",\"folders[].name\",\"folders[].object\",\"folders[].parent_folder_id\",\"folders[].space_id\",\"hasMore\",\"id\",\"legal_holds\",\"legal_holds[].covers_entire_workspace\",\"legal_holds[].created_at\",\"legal_holds[].custodian_count\",\"legal_holds[].description\",\"legal_holds[].id\",\"legal_holds[].name\",\"legal_holds[].object\",\"legal_holds[].released_at\",\"name\",\"notes\",\"notes[].created_at\",\"notes[].deleted_at\",\"notes[].id\",\"notes[].object\",\"notes[].owner\",\"notes[].title\",\"notes[].updated_at\",\"object\",\"owner\",\"private_notes_markdown\",\"private_notes_text\",\"released\",\"released_at\",\"removed\",\"removed_at\",\"scopes\",\"signing_secret\",\"space_membership\",\"space_membership[].id\",\"space_membership[].name\",\"space_membership[].object\",\"spaces\",\"spaces[].id\",\"spaces[].name\",\"spaces[].object\",\"summary_markdown\",\"summary_text\",\"title\",\"transcript\",\"transcript[].end_time\",\"transcript[].speaker\",\"transcript[].start_time\",\"transcript[].text\",\"updated_at\",\"url\",\"url_redacted\",\"web_url\",\"webhook_endpoints\",\"webhook_endpoints[].created_at\",\"webhook_endpoints[].created_by\",\"webhook_endpoints[].enabled\",\"webhook_endpoints[].events\",\"webhook_endpoints[].folder_ids\",\"webhook_endpoints[].id\",\"webhook_endpoints[].object\",\"webhook_endpoints[].scopes\",\"webhook_endpoints[].url\",\"webhook_endpoints[].url_redacted\"],\"d\":[[14,26,27,28,29,30,31,32,33,34,48],[14,42,43,44,45,46,47,48],[14,48,50,51,52,53,54,55,56,57,58],[11,12,15,24,49,59,68,73],[49,68,72,73],[14,16,17,18,19,20,21,48],[16,17,18,19,20,21],[49,68,74,75],[14,48,60,61,62,63,64,65,66,67],[0,1,2,3,4,5,6,7,8,9,10,12,23,36,37,38,39,40,41,49,68,69,70,71,78,79,80,81,86,87,88,89,90,91,92,93,94,97],[14,48,89,90,91,92,93],[14,48,82,83,84,85],[98,99,100,101,102,103,104,105,106,107,108],[12,13,25,26,35,49,68,76,77,95,96],[12,13,25,26,35,49,68,76,95,96],[22,49,68]],\"o\":[0,1,2,3,3,3,4,5,6,7,8,9,10,11,12,13,14,15]}",
|
|
30
|
+
}, { "length": 3267, "definitions": "[{\"a\":[{\"t\":\"string\",\"f\":\"date\"},{\"t\":\"string\",\"f\":\"date-time\"}]},{\"t\":\"object\",\"p\":{\"email\":{\"t\":\"string\",\"f\":\"email\"},\"id\":{\"t\":\"string\"}}},{\"t\":\"string\",\"e\":[\"note.access_granted\",\"note.edited\",\"note.generated\"]}]", "parameters": ["[\"action\",\"query\",0,{\"t\":\"string\"}]", "[\"occurred_before\",\"query\",0,{\"$\":0}]", "[\"occurred_after\",\"query\",0,{\"$\":0}]", "[\"cursor\",\"query\",0,{\"t\":\"string\"}]", "[\"page_size\",\"query\",0,{\"t\":\"integer\",\"l\":1,\"u\":30}]", "[\"created_before\",\"query\",0,{\"$\":0}]", "[\"created_after\",\"query\",0,{\"$\":0}]", "[\"updated_after\",\"query\",0,{\"$\":0}]", "[\"folder_id\",\"query\",0,{\"t\":\"string\"}]", "[\"space_id\",\"query\",0,{\"t\":\"string\"}]", "[\"include\",\"query\",0,{\"t\":\"string\",\"e\":[\"transcript\"]}]", "[\"page_size\",\"query\",0,{\"t\":\"integer\",\"l\":1,\"u\":100}]"], "operations": [[[0, 1, 2, 3, 4], "0"], [[3, 4], "0"], [[3, 4], "0"], [[], "[\"application/json\",{\"t\":\"object\",\"p\":{\"name\":{\"t\":\"string\",\"d\":\"A name for the matter this hold preserves\"},\"description\":{\"t\":[\"string\",\"null\"],\"d\":\"Free text describing the matter\"},\"covers_entire_workspace\":{\"t\":\"boolean\",\"d\":\"Whether the hold covers every member of your workspace, current and future.\"},\"custodians\":{\"t\":\"array\",\"d\":\"Users to name individually when the hold is created\",\"i\":{\"$\":1}}},\"r\":[\"name\",\"covers_entire_workspace\"]}]"], 0, [[], "[\"application/json\",{\"t\":\"object\",\"p\":{\"name\":{\"t\":\"string\",\"d\":\"A new name for the matter\"},\"description\":{\"t\":[\"string\",\"null\"],\"d\":\"New free text for the matter, or null to clear it\"},\"covers_entire_workspace\":{\"t\":\"boolean\",\"d\":\"Whether the hold covers every member of your workspace.\"}}}]"], 0, [[3, 4], "0"], [[], "[\"application/json\",{\"t\":\"object\",\"p\":{\"custodians\":{\"t\":\"array\",\"d\":\"Users to name on this hold\",\"i\":{\"$\":1}}},\"r\":[\"custodians\"]}]"], 0, [[5, 6, 7, 8, 3, 4, 9], "0"], [[10], "0"], [[3, 11], "0"], [[3, 4], "0"], 0, [[], "[\"application/json\",{\"t\":\"object\",\"p\":{\"url\":{\"t\":\"string\",\"f\":\"uri\",\"d\":\"The publicly reachable HTTPS URL to deliver events to\"},\"scopes\":{\"t\":\"array\",\"d\":\"Which notes to receive events for.\",\"i\":{\"t\":\"string\",\"e\":[\"personal\",\"public\",\"workspace\"]}},\"events\":{\"t\":\"array\",\"d\":\"Event names to subscribe to.\",\"i\":{\"$\":2}},\"folder_ids\":{\"t\":\"array\",\"d\":\"Restrict delivery to notes in these folders or any of their subfolders.\",\"i\":{\"t\":\"string\"}}},\"r\":[\"url\",\"scopes\"]}]"], [[], "[\"application/json\",{\"t\":\"object\",\"p\":{\"url\":{\"t\":\"string\",\"f\":\"uri\",\"d\":\"The publicly reachable HTTPS URL to deliver events to.\"},\"scopes\":{\"t\":\"array\",\"d\":\"Which notes to receive events for; replaces the current scopes.\",\"i\":{\"t\":\"string\",\"e\":[\"personal\",\"public\",\"workspace\"]}},\"events\":{\"t\":\"array\",\"d\":\"Event names to subscribe to; replaces the current subscriptions.\",\"i\":{\"$\":2}},\"folder_ids\":{\"t\":\"array\",\"d\":\"Restrict delivery to notes in these folders or any of their subfolders; replaces the current folder filter.\",\"i\":{\"t\":\"string\"}},\"enabled\":{\"t\":\"boolean\",\"d\":\"Pause (`false`) or resume (`true`) event deliveries.\"}}}]"], 0] });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { safe, redact, refuse } from "../_shared/rest/value-safety.js";
|
|
2
|
+
import { openapi } from "./openapi.generated.js";
|
|
3
|
+
export const READS = new Set([
|
|
4
|
+
"GET /v1/notes",
|
|
5
|
+
"GET /v1/notes/{note_id}",
|
|
6
|
+
"GET /v1/notes/{note_id}/transcript",
|
|
7
|
+
"GET /v1/folders",
|
|
8
|
+
"GET /v1/spaces",
|
|
9
|
+
]);
|
|
10
|
+
export const GRANOLA_VALUE_SAFETY = {
|
|
11
|
+
title: "Granola",
|
|
12
|
+
operations: Object.fromEntries(openapi.ops.map(([method, path]) => {
|
|
13
|
+
const key = `${method} ${path}`;
|
|
14
|
+
return [
|
|
15
|
+
key,
|
|
16
|
+
!READS.has(key)
|
|
17
|
+
? refuse("Only notes, note details, transcripts, folders and spaces are reviewed. Audit, legal holds, webhook management and writes are unavailable.")
|
|
18
|
+
: key === "GET /v1/notes/{note_id}"
|
|
19
|
+
? redact("Photo URLs grant unauthenticated access and are always withheld. Private handwritten note content (vendor-null for workspace keys) and space memberships describe API-key visibility, not credentials; their nested values still cross value safety.", ["attachments[].url"], ["private_notes_text", "private_notes_markdown", "space_membership"])
|
|
20
|
+
: safe("Read-only meeting content and identifiers; no API credential is returned. Vendor error prose stays withheld."),
|
|
21
|
+
];
|
|
22
|
+
})),
|
|
23
|
+
embeddedUrls: {
|
|
24
|
+
operations: {
|
|
25
|
+
"GET /v1/notes": ["notes[].title"],
|
|
26
|
+
"GET /v1/notes/{note_id}": [
|
|
27
|
+
"title",
|
|
28
|
+
"summary_text",
|
|
29
|
+
"summary_markdown",
|
|
30
|
+
"private_notes_text",
|
|
31
|
+
"private_notes_markdown",
|
|
32
|
+
"transcript[].text",
|
|
33
|
+
],
|
|
34
|
+
"GET /v1/notes/{note_id}/transcript": ["transcript[].text"],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Generated by scripts/generate-providers.mjs; do not edit.
|
|
2
|
+
export const skill = {
|
|
3
|
+
"content": "## Accessible meeting records\n\nWorkspace keys reach public workspace notes and spaces explicitly enabled for\nGranola API access. Folder permissions inherit their space's setting. They do\nnot grant unrelated private notes. Only notes with a generated AI summary and\ntranscript appear; a missing result does not establish that a meeting never\nhappened. The owner's private handwritten notes are null for workspace keys.\n\nDiscover actual note, folder and space IDs. API IDs use not_, fol_ and spa_\nprefixes; a web-link UUID, title or human name is not an API ID. Notes can\nbelong to several folders and spaces; folder parent_folder_id describes the\nhierarchy and space_id joins a folder to its space. Folder filtering includes\ndescendant folders. Null space_id can also mean an inaccessible space.\n\nOperation search searches the API contract, not meeting content. The REST API\noffers date, folder and space filters, with no natural-language meeting search.\nFilter accessible notes and inspect their contents to answer a topic question;\ndisclose the period and scope examined.\n\n## Transcript interpretation\n\nInline transcripts are optional. If including one fails with HTTP 413\n(TRANSCRIPT_TOO_LARGE), read note details without include and retrieve the\nseparate transcript endpoint through its complete continuation sequence. No\npartial transcript proves a complete quotation or count. An empty page can\nstill have a continuation; follow the returned paging state.\n\nSpeaker source describes the audio input, not a person's identity. macOS may\ndistinguish microphone from speaker audio. iOS currently uses microphone and\nmay supply anonymous diarization labels; do not invent attendee attribution.\nPreserve start/end timestamps when quoting. Summaries are AI-generated, so\nconfirm important wording against the transcript. Return only the relevant\npassages and identifying meeting metadata.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `granola_api_details`, `granola_api_read`, `granola_api_search`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. Keep the original method, path, filters, page size, fields and headers. The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `granola_api_search` finds this API's operations and reads no account data, `granola_api_details` shows one operation's contract, and `granola_api_read` calls a reviewed read. An operation without a reviewed verdict is refused. 13 operations are refused by design; use `granola_api_details` with the method and path to inspect the original operation summary and its separate refusal. Use details for refused methods outside the read/write call schemas; those methods remain unavailable.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. An unknown id, or one these credentials cannot reach, fails as `connector_call_failed`; this API does not tell the two apart. It documents no conflict.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
|
|
4
|
+
"instructionsHeading": "Workspace instructions"
|
|
5
|
+
};
|