@zackbart/connecta 0.14.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/dist/catalog-service.d.ts.map +1 -1
  3. package/dist/catalog-service.js +104 -56
  4. package/dist/catalog-service.js.map +1 -1
  5. package/dist/catalog.d.ts +8 -2
  6. package/dist/catalog.d.ts.map +1 -1
  7. package/dist/catalog.js +58 -9
  8. package/dist/catalog.js.map +1 -1
  9. package/dist/connectors/api.d.ts +4 -4
  10. package/dist/connectors/api.js +4 -4
  11. package/dist/execute.d.ts.map +1 -1
  12. package/dist/execute.js +8 -7
  13. package/dist/execute.js.map +1 -1
  14. package/dist/meta-tools.js +1 -1
  15. package/dist/meta-tools.js.map +1 -1
  16. package/dist/providers/cloudflare.d.ts +5 -1
  17. package/dist/providers/cloudflare.d.ts.map +1 -1
  18. package/dist/providers/cloudflare.js +203 -70
  19. package/dist/providers/cloudflare.js.map +1 -1
  20. package/dist/providers/linear.d.ts.map +1 -1
  21. package/dist/providers/linear.js +4 -3
  22. package/dist/providers/linear.js.map +1 -1
  23. package/dist/providers/mixpanel.d.ts.map +1 -1
  24. package/dist/providers/mixpanel.js +4 -3
  25. package/dist/providers/mixpanel.js.map +1 -1
  26. package/dist/providers/stripe.d.ts.map +1 -1
  27. package/dist/providers/stripe.js +4 -3
  28. package/dist/providers/stripe.js.map +1 -1
  29. package/dist/validate.d.ts.map +1 -1
  30. package/dist/validate.js +97 -17
  31. package/dist/validate.js.map +1 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/documentation/cloudflare.md +37 -19
  35. package/documentation/code-mode.md +9 -9
  36. package/documentation/connectors.md +9 -7
  37. package/documentation/linear.md +6 -7
  38. package/documentation/meta-tools.md +56 -13
  39. package/documentation/mixpanel.md +6 -7
  40. package/documentation/stripe.md +12 -12
  41. package/ethos.md +2 -1
  42. package/package.json +1 -1
  43. package/src/catalog-service.ts +93 -29
  44. package/src/catalog.ts +71 -8
  45. package/src/connectors/api.ts +4 -4
  46. package/src/execute.ts +8 -7
  47. package/src/meta-tools.ts +1 -1
  48. package/src/providers/cloudflare.ts +275 -79
  49. package/src/providers/linear.ts +4 -3
  50. package/src/providers/mixpanel.ts +4 -3
  51. package/src/providers/stripe.ts +4 -3
  52. package/src/validate.ts +125 -20
  53. package/src/version.ts +1 -1
  54. package/templates/node/package.json +1 -1
@@ -28,6 +28,9 @@ import type {
28
28
  /** Cloudflare's v4 REST base. Override only for a proxy or a test double. */
29
29
  export const CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4";
30
30
 
31
+ /** Authentication schemes accepted by Cloudflare's v4 API. */
32
+ export type CloudflareAuthentication = "apiToken" | "globalApiKey";
33
+
31
34
  /**
32
35
  * Every DNS record type the records API accepts, for filtering a list.
33
36
  * Enumerated in the schema so an agent picks a legal type without reading
@@ -100,7 +103,9 @@ export interface CloudflareOptions {
100
103
  zoneId?: string;
101
104
  /** API base override for a proxy or a test double. Defaults to the v4 API. */
102
105
  baseUrl?: string;
103
- /** Credential presentation override; the token is always operator-managed. */
106
+ /** Authentication scheme. Defaults to the recommended scoped API token. */
107
+ authentication?: CloudflareAuthentication;
108
+ /** Credential presentation override; credentials are always operator-managed. */
104
109
  credential?: ConnectorCredentialConfig;
105
110
  /** Account-specific conventions appended to the maintained provider guide. */
106
111
  instructions?: string;
@@ -132,13 +137,65 @@ function admissionPolicy(maxConcurrency: number): ConnectorCallAdmissionPolicy {
132
137
  };
133
138
  }
134
139
 
135
- const DEFAULT_CREDENTIAL: ConnectorCredentialConfig = {
140
+ const API_TOKEN_CREDENTIAL: ConnectorCredentialConfig = {
136
141
  label: "Cloudflare API token",
137
142
  description:
138
143
  "A scoped API token (My Profile → API Tokens → Create Token), not a Global API Key. Grant only the permissions the deployment needs: zone-scoped \"Zone Read\", \"Zone Settings Write\", \"DNS Write\", \"Cache Purge\", and the phase-specific Rules product Read permissions as needed; account-scoped \"Workers Scripts Read/Write\", \"Workers KV Storage Read/Write\", \"Workers R2 Storage Read/Write\", or \"Cloudflare Pages Read/Write\" for the platform tools.",
139
144
  placeholder: "Paste API token",
140
145
  };
141
146
 
147
+ const GLOBAL_API_KEY_CREDENTIAL: ConnectorCredentialConfig = {
148
+ label: "Cloudflare Global API Key",
149
+ description:
150
+ "Legacy user-scoped authentication. The key has the same access as its Cloudflare user across every account and zone that user can reach. Prefer a scoped API token when possible.",
151
+ fields: [
152
+ {
153
+ name: "email",
154
+ label: "Account email",
155
+ description: "The verified email address for the Cloudflare user that owns the Global API Key.",
156
+ placeholder: "you@example.com",
157
+ inputType: "email",
158
+ },
159
+ {
160
+ name: "apiKey",
161
+ label: "Global API Key",
162
+ description: "The legacy Global API Key from My Profile → API Tokens.",
163
+ placeholder: "Paste Global API Key",
164
+ inputType: "password",
165
+ },
166
+ ],
167
+ };
168
+
169
+ function credentialConfig(
170
+ authentication: CloudflareAuthentication,
171
+ override: ConnectorCredentialConfig | undefined,
172
+ ): ConnectorCredentialConfig {
173
+ if (authentication === "apiToken") {
174
+ const credential = override ?? API_TOKEN_CREDENTIAL;
175
+ if (credential.fields?.length) {
176
+ throw new Error(
177
+ "cloudflare() API token authentication requires a single-value credential.",
178
+ );
179
+ }
180
+ return credential;
181
+ }
182
+
183
+ const credential = override
184
+ ? {
185
+ ...GLOBAL_API_KEY_CREDENTIAL,
186
+ ...override,
187
+ fields: override.fields ?? GLOBAL_API_KEY_CREDENTIAL.fields!,
188
+ }
189
+ : GLOBAL_API_KEY_CREDENTIAL;
190
+ const fields = credential.fields?.map((field) => field.name).sort();
191
+ if (fields?.join(",") !== "apiKey,email") {
192
+ throw new Error(
193
+ 'cloudflare() Global API Key authentication requires credential fields named "email" and "apiKey".',
194
+ );
195
+ }
196
+ return credential;
197
+ }
198
+
142
199
  // --- Cloudflare's response envelope -----------------------------------------
143
200
 
144
201
  interface CloudflareEnvelopeError {
@@ -273,7 +330,7 @@ function failureFor(
273
330
  if (status === 401 || status === 403 || authCoded) {
274
331
  return new ConnectorCallError(
275
332
  "auth_required",
276
- `Cloudflare rejected the API token (HTTP ${status}). ${detail} Check that the token is valid and carries the permission this call needs.`,
333
+ `Cloudflare rejected the configured credential (HTTP ${status}). ${detail} Check that it is valid and has permission to access this resource.`,
277
334
  );
278
335
  }
279
336
  if (status === 400 || status === 409 || status === 422) {
@@ -316,7 +373,35 @@ interface CloudflareResponse {
316
373
  resultInfo: CloudflareResultInfo | undefined;
317
374
  }
318
375
 
319
- async function readToken(ctx: ConnectorContext): Promise<string> {
376
+ const AUTHENTICATION_CONTEXT = Symbol("cloudflareAuthentication");
377
+
378
+ type CloudflareContext = ConnectorContext & {
379
+ [AUTHENTICATION_CONTEXT]?: CloudflareAuthentication;
380
+ };
381
+
382
+ function withAuthentication(
383
+ ctx: ConnectorContext,
384
+ authentication: CloudflareAuthentication,
385
+ ): CloudflareContext {
386
+ return { ...ctx, [AUTHENTICATION_CONTEXT]: authentication };
387
+ }
388
+
389
+ async function readAuthenticationHeaders(
390
+ ctx: CloudflareContext,
391
+ ): Promise<Record<string, string>> {
392
+ if (ctx[AUTHENTICATION_CONTEXT] === "globalApiKey") {
393
+ const values = await ctx.credential?.getAll();
394
+ const email = values?.["email"];
395
+ const apiKey = values?.["apiKey"];
396
+ if (!email || !apiKey) {
397
+ throw new ConnectorCallError(
398
+ "auth_required",
399
+ "No Cloudflare Global API Key and account email are configured for this connector. An operator must add both before any call can run.",
400
+ );
401
+ }
402
+ return { "X-Auth-Email": email, "X-Auth-Key": apiKey };
403
+ }
404
+
320
405
  const token = await ctx.credential?.get();
321
406
  if (!token) {
322
407
  throw new ConnectorCallError(
@@ -324,7 +409,7 @@ async function readToken(ctx: ConnectorContext): Promise<string> {
324
409
  "No Cloudflare API token is configured for this connector. An operator must add one before any call can run.",
325
410
  );
326
411
  }
327
- return token;
412
+ return { Authorization: `Bearer ${token}` };
328
413
  }
329
414
 
330
415
  function buildUrl(base: string, spec: RequestSpec): string {
@@ -341,7 +426,7 @@ async function fetchCloudflare(
341
426
  spec: RequestSpec,
342
427
  ctx: ConnectorContext,
343
428
  ): Promise<Response> {
344
- const token = await readToken(ctx);
429
+ const authenticationHeaders = await readAuthenticationHeaders(ctx);
345
430
  if (spec.body !== undefined && spec.rawBody !== undefined) {
346
431
  throw new Error("A Cloudflare request cannot have both JSON and raw bodies.");
347
432
  }
@@ -350,7 +435,7 @@ async function fetchCloudflare(
350
435
  response = await fetch(buildUrl(base, spec), {
351
436
  method: spec.method,
352
437
  headers: {
353
- Authorization: `Bearer ${token}`,
438
+ ...authenticationHeaders,
354
439
  Accept: "application/json",
355
440
  ...(spec.body !== undefined
356
441
  ? { "Content-Type": "application/json" }
@@ -405,9 +490,17 @@ async function callCloudflare(
405
490
  if (!response.ok || envelope.success === false) {
406
491
  throw failureFor(response.status, response.headers, errors);
407
492
  }
493
+ const isV4Envelope =
494
+ "success" in envelope ||
495
+ "result" in envelope ||
496
+ "result_info" in envelope ||
497
+ "messages" in envelope;
408
498
  return {
409
- result: envelope.result,
410
- resultInfo: envelope.result_info,
499
+ // `/graphql` and a small number of product APIs return ordinary JSON
500
+ // instead of the standard v4 envelope. Preserve that document whole so
501
+ // the raw tools cover them too.
502
+ result: isV4Envelope ? envelope.result : envelope,
503
+ resultInfo: isV4Envelope ? envelope.result_info : undefined,
411
504
  };
412
505
  }
413
506
 
@@ -1031,6 +1124,8 @@ function headersFromArgs(value: unknown): Record<string, string> | undefined {
1031
1124
  const headers: Record<string, string> = {};
1032
1125
  const forbidden = new Set([
1033
1126
  "authorization",
1127
+ "x-auth-email",
1128
+ "x-auth-key",
1034
1129
  "cookie",
1035
1130
  "host",
1036
1131
  "content-length",
@@ -1206,7 +1301,10 @@ const R2_OBJECT_SCHEMA: JsonSchema = {
1206
1301
  required: ["key"],
1207
1302
  };
1208
1303
 
1209
- function buildTools(scope: Scoping): ApiTool[] {
1304
+ function buildTools(
1305
+ scope: Scoping,
1306
+ authentication: CloudflareAuthentication,
1307
+ ): ApiTool[] {
1210
1308
  const { base } = scope;
1211
1309
  const zoneArg = (args: JsonRecord): string =>
1212
1310
  requireScope(args["zoneId"], scope.zoneId, "zoneId");
@@ -1215,54 +1313,88 @@ function buildTools(scope: Scoping): ApiTool[] {
1215
1313
 
1216
1314
  const readOnly = { readOnlyHint: true, destructiveHint: false } as const;
1217
1315
 
1218
- return [
1219
- {
1220
- name: "verify_api_token",
1221
- description:
1222
- "Verify the configured Cloudflare API token and report its status. Use this first when any other tool fails with an authentication error, to separate a bad token from a missing permission.",
1223
- annotations: readOnly,
1224
- inputSchema: {
1225
- type: "object",
1226
- properties: {},
1227
- required: [],
1228
- additionalProperties: false,
1229
- },
1230
- outputSchema: {
1231
- type: "object",
1232
- properties: {
1233
- id: { type: "string" },
1234
- status: {
1235
- type: "string",
1236
- description: "\"active\" for a usable token.",
1316
+ const tools: ApiTool[] = [
1317
+ authentication === "apiToken"
1318
+ ? {
1319
+ name: "verify_api_token",
1320
+ description:
1321
+ "Verify the configured Cloudflare API token and report its status. Use this first when any other tool fails with an authentication error, to separate a bad token from a missing permission.",
1322
+ annotations: readOnly,
1323
+ inputSchema: {
1324
+ type: "object",
1325
+ properties: {},
1326
+ required: [],
1327
+ additionalProperties: false,
1328
+ },
1329
+ outputSchema: {
1330
+ type: "object",
1331
+ properties: {
1332
+ id: { type: "string" },
1333
+ status: {
1334
+ type: "string",
1335
+ description: "\"active\" for a usable token.",
1336
+ },
1337
+ notBefore: { type: "string" },
1338
+ expiresOn: { type: "string" },
1339
+ },
1340
+ required: ["status"],
1341
+ },
1342
+ handler: async (_args, ctx) => {
1343
+ const { result } = await callCloudflare(
1344
+ base,
1345
+ { method: "GET", path: "/user/tokens/verify" },
1346
+ ctx,
1347
+ );
1348
+ const token = asRecord(result);
1349
+ return {
1350
+ id: token["id"],
1351
+ status: token["status"],
1352
+ ...(token["not_before"] !== undefined
1353
+ ? { notBefore: token["not_before"] }
1354
+ : {}),
1355
+ ...(token["expires_on"] !== undefined
1356
+ ? { expiresOn: token["expires_on"] }
1357
+ : {}),
1358
+ };
1359
+ },
1360
+ }
1361
+ : {
1362
+ name: "verify_global_api_key",
1363
+ description:
1364
+ "Verify the configured Cloudflare Global API Key and account email by retrieving the authenticated user. Use this first when another tool fails with an authentication error.",
1365
+ annotations: readOnly,
1366
+ inputSchema: {
1367
+ type: "object",
1368
+ properties: {},
1369
+ required: [],
1370
+ additionalProperties: false,
1371
+ },
1372
+ outputSchema: {
1373
+ type: "object",
1374
+ properties: {
1375
+ id: { type: "string" },
1376
+ email: { type: "string" },
1377
+ status: {
1378
+ type: "string",
1379
+ description: "\"active\" when Cloudflare accepts the email and key.",
1380
+ },
1381
+ },
1382
+ required: ["email", "status"],
1383
+ },
1384
+ handler: async (_args, ctx) => {
1385
+ const { result } = await callCloudflare(
1386
+ base,
1387
+ { method: "GET", path: "/user" },
1388
+ ctx,
1389
+ );
1390
+ const user = asRecord(result);
1391
+ return { id: user["id"], email: user["email"], status: "active" };
1237
1392
  },
1238
- notBefore: { type: "string" },
1239
- expiresOn: { type: "string" },
1240
1393
  },
1241
- required: ["status"],
1242
- },
1243
- handler: async (_args, ctx) => {
1244
- const { result } = await callCloudflare(
1245
- base,
1246
- { method: "GET", path: "/user/tokens/verify" },
1247
- ctx,
1248
- );
1249
- const token = asRecord(result);
1250
- return {
1251
- id: token["id"],
1252
- status: token["status"],
1253
- ...(token["not_before"] !== undefined
1254
- ? { notBefore: token["not_before"] }
1255
- : {}),
1256
- ...(token["expires_on"] !== undefined
1257
- ? { expiresOn: token["expires_on"] }
1258
- : {}),
1259
- };
1260
- },
1261
- },
1262
1394
  {
1263
1395
  name: "cloudflare_api_get",
1264
1396
  description:
1265
- "Call any GET endpoint under Cloudflare's v4 API with this connector's token. Use a named tool when one exists; use this read-only escape hatch for Images, Stream, Email Routing, D1, Queues, Access, Tunnels, Analytics, and newer product endpoints the curated surface does not yet name.",
1397
+ "Call any GET endpoint under Cloudflare's v4 API with this connector's credential. Use a named tool when one exists; use this read-only escape hatch for Images, Stream, Email Routing, D1, Queues, Access, Tunnels, Analytics, and newer product endpoints the curated surface does not yet name.",
1266
1398
  annotations: readOnly,
1267
1399
  inputSchema: {
1268
1400
  type: "object",
@@ -1330,7 +1462,7 @@ function buildTools(scope: Scoping): ApiTool[] {
1330
1462
  {
1331
1463
  name: "cloudflare_api_mutate",
1332
1464
  description:
1333
- "Call any JSON POST, PUT, PATCH, or DELETE endpoint under Cloudflare's v4 API with this connector's token. This is the approval-gated escape hatch for managing Cloudflare products without waiting for a named tool. It does not support multipart or binary uploads.",
1465
+ "Call any JSON POST, PUT, PATCH, or DELETE endpoint under Cloudflare's v4 API with this connector's credential. This is the approval-gated escape hatch for managing Cloudflare products without waiting for a named tool. It does not support multipart or binary uploads.",
1334
1466
  annotations: { readOnlyHint: false, destructiveHint: true },
1335
1467
  inputSchema: {
1336
1468
  type: "object",
@@ -3715,12 +3847,18 @@ function buildTools(scope: Scoping): ApiTool[] {
3715
3847
  },
3716
3848
  },
3717
3849
  ];
3850
+ return tools.map((tool) => ({
3851
+ ...tool,
3852
+ handler: (args, ctx) =>
3853
+ tool.handler(args, withAuthentication(ctx, authentication)),
3854
+ }));
3718
3855
  }
3719
3856
 
3720
3857
  function usageGuide(
3721
3858
  purpose: string,
3722
3859
  scope: Scoping,
3723
3860
  instructions: string | undefined,
3861
+ authentication: CloudflareAuthentication,
3724
3862
  ): string {
3725
3863
  const accountInstructions = instructions?.trim();
3726
3864
  const zoneLine = scope.zoneId
@@ -3729,6 +3867,10 @@ function usageGuide(
3729
3867
  const accountLine = scope.accountId
3730
3868
  ? `It defaults to account \`${scope.accountId}\`; omit \`accountId\` unless the request names a different account.`
3731
3869
  : "It declares no default account. `list_accounts` supplies the `accountId` the Workers, KV, R2, and Pages tools need.";
3870
+ const authenticationLine =
3871
+ authentication === "apiToken"
3872
+ ? "The API token is operator-managed and scoped by permission. An `auth_required` failure means the token is missing, invalid, or lacks that call's permission. Call `verify_api_token` first."
3873
+ : "The Global API Key and account email are operator-managed. The key has the same access as its Cloudflare user. An `auth_required` failure means one field is missing, the pair is invalid, or the user lacks access. Call `verify_global_api_key` first.";
3732
3874
  return `# Cloudflare usage
3733
3875
 
3734
3876
  Account purpose: ${purpose}
@@ -3736,11 +3878,11 @@ Account purpose: ${purpose}
3736
3878
  - ${zoneLine}
3737
3879
  - ${accountLine}
3738
3880
  - Prefer a named tool: its schema is complete, projected, and enough to call it without provider documentation. For an operation without a named tool, use \`cloudflare_api_get\` for GET, \`cloudflare_api_mutate\` for JSON POST/PUT/PATCH/DELETE, or \`cloudflare_api_upload\` for raw and multipart content. Raw tools take a path below \`/client/v4\`; their argument schemas are complete, but endpoint-specific query, header, and body fields come from Cloudflare's API reference. Use \`headers\` for endpoint-specific controls such as \`cf-r2-jurisdiction\`, ETags, and object metadata; authentication, host, content type, and request framing remain connector-owned.
3739
- - The raw tools cover the wider control plane without weakening routing: GET is explicitly read-only; every mutation and upload is destructive and must cross the host's approval boundary. The API token remains the hard provider-side permission boundary. Absolute URLs, traversal, and query strings embedded in \`path\` are refused locally.
3881
+ - The raw tools cover the wider control plane without weakening routing: GET is explicitly read-only; every mutation and upload is destructive and must cross the host's approval boundary. The configured Cloudflare credential remains the hard provider-side permission boundary. Absolute URLs, traversal, and query strings embedded in \`path\` are refused locally.
3740
3882
  - Useful raw paths include \`/accounts/{accountId}/images/v1\` (Images), \`/accounts/{accountId}/stream\` (Stream), \`/zones/{zoneId}/email/routing/rules\` (Email Routing), \`/accounts/{accountId}/d1/database\` (D1), and \`/accounts/{accountId}/queues\` (Queues). On GET, use \`responseType: "text"\` or \`"base64"\` for non-JSON content. Direct-upload endpoints can issue upload URLs; \`cloudflare_api_upload\` can also send explicit text, base64 bytes, or multipart fields/files.
3741
3883
  - Lists paginate with \`page\` and \`perPage\` and return a \`page\` object; request the next page only when \`page.hasMore\` is true. \`list_zone_rulesets\`, \`list_r2_buckets\`, \`list_r2_objects\`, and \`list_kv_keys\` instead return \`nextCursor\`; \`list_worker_scripts\` is unpaginated.
3742
3884
  - Results are projected to the fields that identify and describe a resource. Pass \`raw: true\` on a read when you genuinely need a field the projection drops.
3743
- - The API token is operator-managed and scoped by permission, not by role. An \`auth_required\` failure means the token is missing, invalid, or lacks that call's permission — it is never fixed by retrying. Call \`verify_api_token\` to tell a dead token from a missing permission, then report which permission is needed rather than trying other tools.
3885
+ - ${authenticationLine}
3744
3886
  - A \`rate_limited\` failure carries the wait window. Cloudflare's limit is 1,200 requests per five minutes per user, counted across the dashboard and every token, so do not fan out speculatively; filter server-side with \`name\`, \`type\`, and \`content\` instead of listing everything and filtering locally.
3745
3887
  - Named creates that only add a resource are write-routed without claiming destruction. Updates, overwrites, deletes, rollbacks, cache purges, \`cloudflare_api_mutate\`, and \`cloudflare_api_upload\` are destructive. Read current state before changing it, and prefer a targeted \`purge_cache\` over \`everything\`.
3746
3888
  ${
@@ -3760,6 +3902,12 @@ export function cloudflare(id: string, options: CloudflareOptions): Connector {
3760
3902
  if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
3761
3903
  throw new Error("cloudflare() maxConcurrency must be a positive integer.");
3762
3904
  }
3905
+ const authentication = options.authentication ?? "apiToken";
3906
+ if (authentication !== "apiToken" && authentication !== "globalApiKey") {
3907
+ throw new Error(
3908
+ 'cloudflare() authentication must be "apiToken" or "globalApiKey".',
3909
+ );
3910
+ }
3763
3911
  const scope: Scoping = {
3764
3912
  base: options.baseUrl?.trim() || CLOUDFLARE_API_BASE,
3765
3913
  accountId: options.accountId?.trim() || undefined,
@@ -3768,36 +3916,84 @@ export function cloudflare(id: string, options: CloudflareOptions): Connector {
3768
3916
  return api(id, {
3769
3917
  title: options.title ?? "Cloudflare",
3770
3918
  description: `Cloudflare control-plane access for zones, DNS, Workers, KV, R2, Pages, media, email, and other v4 APIs — ${purpose}`,
3771
- credential: options.credential ?? DEFAULT_CREDENTIAL,
3919
+ credential: credentialConfig(authentication, options.credential),
3772
3920
  callAdmission: admissionPolicy(maxConcurrency),
3773
- usageGuide: usageGuide(purpose, scope, options.instructions),
3921
+ usageGuide: usageGuide(
3922
+ purpose,
3923
+ scope,
3924
+ options.instructions,
3925
+ authentication,
3926
+ ),
3774
3927
  // The schemas are hand-written and closed; a schema that cannot be
3775
3928
  // enforced is a bug in this file, not input to pass through.
3776
3929
  strictValidation: true,
3777
3930
  ...(options.maxResultBytes !== undefined
3778
3931
  ? { maxResultBytes: options.maxResultBytes }
3779
3932
  : {}),
3780
- tools: buildTools(scope),
3781
- async testCredential(value, ctx) {
3782
- try {
3783
- const { result } = await callCloudflare(
3784
- scope.base,
3785
- { method: "GET", path: "/user/tokens/verify" },
3786
- {
3787
- ...ctx,
3788
- credential: { get: async () => value, getAll: async () => ({ value }) },
3933
+ tools: buildTools(scope, authentication),
3934
+ ...(authentication === "apiToken"
3935
+ ? {
3936
+ async testCredential(value: string, ctx: ConnectorContext) {
3937
+ try {
3938
+ const { result } = await callCloudflare(
3939
+ scope.base,
3940
+ { method: "GET", path: "/user/tokens/verify" },
3941
+ withAuthentication(
3942
+ {
3943
+ ...ctx,
3944
+ credential: {
3945
+ get: async () => value,
3946
+ getAll: async () => ({ value }),
3947
+ },
3948
+ },
3949
+ authentication,
3950
+ ),
3951
+ );
3952
+ const status = asRecord(result)["status"];
3953
+ return status === "active"
3954
+ ? { ok: true, message: "Token verified: active." }
3955
+ : { ok: false, message: `Token status is "${String(status)}".` };
3956
+ } catch (error) {
3957
+ return {
3958
+ ok: false,
3959
+ message: error instanceof Error ? error.message : String(error),
3960
+ };
3961
+ }
3789
3962
  },
3790
- );
3791
- const status = asRecord(result)["status"];
3792
- return status === "active"
3793
- ? { ok: true, message: "Token verified: active." }
3794
- : { ok: false, message: `Token status is "${String(status)}".` };
3795
- } catch (error) {
3796
- return {
3797
- ok: false,
3798
- message: error instanceof Error ? error.message : String(error),
3799
- };
3800
- }
3801
- },
3963
+ }
3964
+ : {
3965
+ async testCredentials(
3966
+ values: Record<string, string>,
3967
+ ctx: ConnectorContext,
3968
+ ) {
3969
+ try {
3970
+ const { result } = await callCloudflare(
3971
+ scope.base,
3972
+ { method: "GET", path: "/user" },
3973
+ withAuthentication(
3974
+ {
3975
+ ...ctx,
3976
+ credential: {
3977
+ get: async (field?: string) =>
3978
+ field ? values[field] ?? null : null,
3979
+ getAll: async () => values,
3980
+ },
3981
+ },
3982
+ authentication,
3983
+ ),
3984
+ );
3985
+ const email = asRecord(result)["email"];
3986
+ return {
3987
+ ok: true,
3988
+ message: `Global API Key verified for ${String(email)}.`,
3989
+ };
3990
+ } catch (error) {
3991
+ return {
3992
+ ok: false,
3993
+ message: error instanceof Error ? error.message : String(error),
3994
+ };
3995
+ }
3996
+ },
3997
+ }),
3802
3998
  });
3803
3999
  }
@@ -173,17 +173,18 @@ const WRITE_TOOLS: ReadonlyMap<string, "additive" | "destructive"> = new Map([
173
173
  ]);
174
174
 
175
175
  /**
176
- * Fill in what the downstream leaves unsaid; never argue with what it says.
176
+ * Fill in downstream silence; keep reviewed destructive tools fail-closed.
177
177
  *
178
178
  * Silence is what a vetted classification is for, and an explicit downstream
179
- * annotation wins in both directions. `destructiveHint: true` or
179
+ * annotation otherwise wins in both directions. `destructiveHint: true` or
180
180
  * `readOnlyHint: false` on an allowlisted read name is the downstream telling
181
181
  * us this release's allowlist is stale; `readOnlyHint: true` on a name no
182
182
  * release has classified says the same thing from the other side. The single
183
183
  * place a vetted verdict still overrides the downstream is a name this release
184
184
  * reviewed and filed destructive: there connecta knows what the tool does, and
185
185
  * a claim to the contrary is a downstream bug rather than news
186
- * ([#310](https://github.com/zackbart/connecta/issues/310)).
186
+ * ([#310](https://github.com/zackbart/connecta/issues/310),
187
+ * [#315](https://github.com/zackbart/connecta/issues/315)).
187
188
  */
188
189
  function vettedSafety(definition: ToolDef): ToolDef {
189
190
  const downstream = definition.annotations ?? {};
@@ -125,17 +125,18 @@ const WRITE_TOOLS: ReadonlyMap<string, "additive" | "destructive"> = new Map([
125
125
  ]);
126
126
 
127
127
  /**
128
- * Fill in what the downstream leaves unsaid; never argue with what it says.
128
+ * Fill in downstream silence; keep reviewed destructive tools fail-closed.
129
129
  *
130
130
  * Silence is what a vetted classification is for, and an explicit downstream
131
- * annotation wins in both directions. `destructiveHint: true` or
131
+ * annotation otherwise wins in both directions. `destructiveHint: true` or
132
132
  * `readOnlyHint: false` on an allowlisted read name is the downstream telling
133
133
  * us this release's allowlist is stale; `readOnlyHint: true` on a name no
134
134
  * release has classified says the same thing from the other side. The single
135
135
  * place a vetted verdict still overrides the downstream is a name this release
136
136
  * reviewed and filed destructive: there connecta knows what the tool does, and
137
137
  * a claim to the contrary is a downstream bug rather than news
138
- * ([#310](https://github.com/zackbart/connecta/issues/310)).
138
+ * ([#310](https://github.com/zackbart/connecta/issues/310),
139
+ * [#315](https://github.com/zackbart/connecta/issues/315)).
139
140
  */
140
141
  function vettedSafety(definition: ToolDef): ToolDef {
141
142
  const downstream = definition.annotations ?? {};
@@ -113,17 +113,18 @@ const WRITE_TOOLS: ReadonlyMap<string, "additive" | "destructive"> = new Map([
113
113
  ]);
114
114
 
115
115
  /**
116
- * Fill in what the downstream leaves unsaid; never argue with what it says.
116
+ * Fill in downstream silence; keep reviewed destructive tools fail-closed.
117
117
  *
118
118
  * Silence is what a vetted classification is for, and an explicit downstream
119
- * annotation wins in both directions. `destructiveHint: true` or
119
+ * annotation otherwise wins in both directions. `destructiveHint: true` or
120
120
  * `readOnlyHint: false` on an allowlisted read name is the downstream telling
121
121
  * us this release's allowlist is stale; `readOnlyHint: true` on a name no
122
122
  * release has classified says the same thing from the other side. The single
123
123
  * place a vetted verdict still overrides the downstream is a name this release
124
124
  * reviewed and filed destructive: there connecta knows what the tool does, and
125
125
  * a claim to the contrary is a downstream bug rather than news
126
- * ([#310](https://github.com/zackbart/connecta/issues/310)).
126
+ * ([#310](https://github.com/zackbart/connecta/issues/310),
127
+ * [#315](https://github.com/zackbart/connecta/issues/315)).
127
128
  */
128
129
  function vettedSafety(definition: ToolDef): ToolDef {
129
130
  const downstream = definition.annotations ?? {};