@jskit-ai/connectors-core 0.2.3 → 0.2.5

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.
@@ -1,9 +1,10 @@
1
1
  import * as oauth from "oauth4webapi";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import { createHash } from "node:crypto";
4
- import { createHttpClient } from "@jskit-ai/http-runtime/client";
5
4
  import { registrationClientAuthentication, registrationGrantType, validateIntegrationConfiguration } from "../shared/configuration.js";
5
+ import { createConnectionCredentials } from "./connectionCredentials.js";
6
6
  import { ConnectorError, providerError } from "./errors.js";
7
+ import { connectionProvider, createProviderRequests, operationRequest, requireOperationScope, tokenResponse } from "./providerRequests.js";
7
8
 
8
9
  function publicConnection(connection) {
9
10
  if (!connection) return { status: "disconnected" };
@@ -23,34 +24,6 @@ const ENV_SEEDED_CODE = { messageCode: "connectors.connection.envSeeded" };
23
24
  const VERIFICATION_SCOPE_MESSAGE = "Connection verification requires a permission absent from the application's configuration.";
24
25
  const VERIFICATION_SCOPE_CODE = { messageCode: "connectors.connection.verificationScopeAbsent" };
25
26
 
26
- // An operation without scopes always runs; otherwise one of its scopes must be present.
27
- function requireOperationScope(operation, scopes, message, { messageCode }) {
28
- if (operation.scopes.length && !operation.scopes.some((scope) => scopes.includes(scope))) {
29
- throw new ConnectorError("connector_scope_missing", message, { statusCode: 403, messageCode });
30
- }
31
- }
32
-
33
- // Builds the provider request, reporting schema input errors as connector_input_invalid.
34
- function operationRequest(operation, input, settings, providerData) {
35
- try { return operation.request(input, settings || {}, providerData); } catch (error) {
36
- if (!error.fieldErrors) throw error;
37
- const failure = new ConnectorError("connector_input_invalid", "Check the operation's input values.", { statusCode: 422, messageCode: "connectors.connection.checkOperationInputValues" });
38
- failure.fieldErrors = error.fieldErrors;
39
- if (error.fieldErrorCodes) failure.fieldErrorCodes = error.fieldErrorCodes;
40
- throw failure;
41
- }
42
- }
43
-
44
- function validateCallbackUrl(value) {
45
- let url;
46
- try { url = new URL(value); } catch { /* reported below */ }
47
- const localHttp = url?.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
48
- if (!url || (!localHttp && url.protocol !== "https:") || url.username || url.password || url.hash || url.search) {
49
- throw new ConnectorError("connector_callback_invalid", "Configure an HTTPS callback URL, or a local development callback.", { messageCode: "connectors.connection.configureHttpsCallbackUrlLocal" });
50
- }
51
- return url.href;
52
- }
53
-
54
27
  // Whether a stored connection still belongs to the configured integration and
55
28
  // OAuth client. `registration` is { registrationRef, clientId, grantType,
56
29
  // tokenEndpointAuthMethod } with defaults applied; only OAuth reads it.
@@ -58,7 +31,7 @@ function connectionMatches(connection, integration, providerId, registration) {
58
31
  const method = integration.authentication.method;
59
32
  return connection.provider === providerId &&
60
33
  (connection.method || "oauth2") === method &&
61
- isDeepStrictEqual(connection.settings || {}, integration.settings || {}) &&
34
+ isDeepStrictEqual(connection.settings || {}, integration.settings) &&
62
35
  (!["api-key", "service-account"].includes(method) || connection.secretRef === integration.authentication.secretRef) &&
63
36
  (method !== "service-account" || isDeepStrictEqual(connection.requestedScopes, integration.scopes)) &&
64
37
  (method !== "oauth2" || (connection.registrationRef === registration.registrationRef &&
@@ -74,6 +47,19 @@ function callbackMatches(connection, provider, registration) {
74
47
  connection.callbackUrl === registration.callbackUrl;
75
48
  }
76
49
 
50
+ /**
51
+ * The connections of one application's configured integrations: authorization,
52
+ * connection, provider operations, status and disconnection.
53
+ * @param {object} options
54
+ * @param {Record<string, any>} options.configuration The integration configuration, validated here against the providers.
55
+ * @param {import("./types.js").ConnectorProvider[]} options.providers
56
+ * @param {import("./types.js").ResolveReference} options.resolveReference
57
+ * @param {import("./types.js").ConnectionStore} options.store
58
+ * @param {import("./types.js").ConnectorAuthorize} options.authorize
59
+ * @param {"application" | "assistant"} [options.executionMode]
60
+ * @param {typeof globalThis.fetch} [options.fetchImpl]
61
+ * @param {() => number} [options.now]
62
+ */
77
63
  function createConnectionService({
78
64
  configuration,
79
65
  providers,
@@ -86,27 +72,29 @@ function createConnectionService({
86
72
  }) {
87
73
  if (!["application", "assistant"].includes(executionMode)) throw new TypeError("Select application or assistant execution.");
88
74
  const config = validateIntegrationConfiguration(configuration, { providers });
75
+ // Each integration with its settings resolved, so the service reads them as one object.
76
+ const integrations = Object.fromEntries(Object.entries(config.integrations)
77
+ .map(([id, integration]) => [id, { ...integration, settings: integration.settings || {} }]));
89
78
  const definitions = new Map(providers.map((provider) => [provider.id, provider]));
90
79
  if (!providers.length || definitions.size !== providers.length) throw new TypeError("Register unique connector providers.");
80
+ // readable-check: allow method-probe - input validation: the application passes its access policy and reference resolver
91
81
  if (typeof authorize !== "function" || typeof resolveReference !== "function") {
92
82
  throw new TypeError("Connections require application authorization and reference resolution.");
93
83
  }
84
+ // readable-check: allow method-probe - input validation: the application passes its own connection store
94
85
  if (typeof store?.withConnection !== "function") throw new TypeError("Connection storage requires withConnection().");
95
- const http = createHttpClient({ fetchImpl, csrf: { enabled: false }, credentials: "omit" });
86
+ const connectionCredentials = createConnectionCredentials({ config, resolveReference });
87
+ const providerRequests = createProviderRequests({ fetchImpl, now, resolveReference });
96
88
 
97
89
  async function access(context, integrationId, operation, input, checkAssistantPermission = true) {
98
- const integration = config.integrations[integrationId];
99
- if (!Object.hasOwn(config.integrations, integrationId)) {
90
+ const integration = integrations[integrationId];
91
+ if (!Object.hasOwn(integrations, integrationId)) {
100
92
  throw new ConnectorError("connector_not_found", "This integration is not configured.", { statusCode: 404, messageCode: "connectors.connection.integrationNotConfigured" });
101
93
  }
102
- const baseDefinition = definitions.get(integration.provider);
103
- const definition = baseDefinition.runtimeForSettings
104
- ? { ...baseDefinition, ...baseDefinition.runtimeForSettings(integration.settings || {}) } : baseDefinition;
105
- const provider = typeof definition.checkOperation === "function"
106
- ? { ...definition, checkOperation: definition.checkOperation(integration.authentication.method) }
107
- : definition;
94
+ const provider = connectionProvider(definitions.get(integration.provider), integration);
108
95
  let assistantPermission;
109
96
  if (executionMode === "assistant" && checkAssistantPermission && !["status", "disconnect"].includes(operation)) {
97
+ // A configuration-only definition has no operations; its connection steps refuse it.
110
98
  const action = provider.operations?.[operation]?.assistantAction || operation;
111
99
  const policy = integration.assistantPolicy;
112
100
  const decision = policy?.enabled === false ? "never" : policy?.actions?.[action] || policy?.defaultPermission || "ask";
@@ -132,8 +120,9 @@ function createConnectionService({
132
120
  return { owner: { applicationId: owner.applicationId, subjectId: owner.subjectId }, integration, provider };
133
121
  }
134
122
 
123
+ /** @param {import("./types.js").IntegrationRequest & { action: string, input?: any }} request */
135
124
  async function authorizeAssistantAction({ context, integrationId, action, input = {} }) {
136
- const provider = definitions.get(config.integrations[integrationId]?.provider);
125
+ const provider = definitions.get(integrations[integrationId]?.provider);
137
126
  if (executionMode !== "assistant" || !provider?.assistantActions?.some((item) => item.value === action)) {
138
127
  throw new ConnectorError("connector_operation_unknown", "This provider does not declare this assistant action.", { messageCode: "connectors.connection.providerDoesNotDeclareAssistant" });
139
128
  }
@@ -141,92 +130,6 @@ function createConnectionService({
141
130
  // The host performs its own lifecycle operation after authorization; no provider action is executed here.
142
131
  }
143
132
 
144
- async function registrationFor(integration, provider) {
145
- const registration = config.registrations[integration.authentication.registrationRef];
146
- if (registration?.source !== "own" || integration.authentication.method !== "oauth2" || !provider.oauth) {
147
- throw new ConnectorError("connector_mode_unavailable", "This runtime does not yet support the selected credential mode.", { messageCode: "connectors.connection.runtimeDoesNotYetSupport" });
148
- }
149
- if (registration.clientId.trim() === "MISSING") {
150
- throw new ConnectorError("connector_binding_missing", "The provider client ID is not configured.", { messageCode: "connectors.connection.providerClientIdNotConfigured" });
151
- }
152
- const grantType = registrationGrantType(registration);
153
- const tokenEndpointAuthMethod = registrationClientAuthentication(registration);
154
- if (provider.oauthPkce === false && tokenEndpointAuthMethod === "none") {
155
- throw new ConnectorError("connector_mode_unavailable", "This provider's non-PKCE flow requires a confidential client.", { messageCode: "connectors.connection.providerNonPkceFlowRequires" });
156
- }
157
- let clientSecret;
158
- let callback;
159
- try {
160
- if (tokenEndpointAuthMethod !== "none") {
161
- clientSecret = await resolveReference(registration.clientSecretRef);
162
- if (typeof clientSecret !== "string" || !clientSecret.trim() || clientSecret.trim() === "MISSING") throw new Error("Invalid client secret.");
163
- }
164
- if (grantType === "authorization_code") callback = await resolveReference(registration.callbackUrlRef);
165
- } catch {
166
- throw new ConnectorError("connector_binding_missing", "A required registration binding is missing.", { messageCode: "connectors.connection.requiredRegistrationBindingMissing" });
167
- }
168
- const callbackUrl = grantType === "authorization_code" ? validateCallbackUrl(callback) : undefined;
169
- if (callbackUrl && provider.validateCallbackUrl && provider.validateCallbackUrl(callbackUrl) !== true) {
170
- throw new ConnectorError("connector_callback_invalid", "The callback URL does not meet this provider's requirements.", { messageCode: "connectors.connection.callbackUrlDoesNotMeet" });
171
- }
172
- let clientAuth = tokenEndpointAuthMethod === "none" ? oauth.None()
173
- : tokenEndpointAuthMethod === "client_secret_basic" ? oauth.ClientSecretBasic(clientSecret) : oauth.ClientSecretPost(clientSecret);
174
- if (tokenEndpointAuthMethod === "client_secret_basic" && provider.oauthBasicEncoding === "raw") {
175
- clientAuth = (_as, client, _body, headers) => {
176
- headers.set("authorization", `Basic ${Buffer.from(`${client.client_id}:${clientSecret}`).toString("base64")}`);
177
- };
178
- }
179
- return {
180
- oauth: typeof provider.oauth === "function" ? provider.oauth(integration.settings || {}) : provider.oauth,
181
- resource: typeof provider.oauthResource === "function" ? provider.oauthResource(integration.settings || {}) : provider.oauthResource,
182
- client: { client_id: registration.clientId },
183
- clientAuth: provider.oauthClientIdParameter ? (as, client, body, headers) => {
184
- clientAuth(as, client, body, headers);
185
- body.set(provider.oauthClientIdParameter, client.client_id);
186
- body.delete("client_id");
187
- } : clientAuth,
188
- tokenEndpointAuthMethod,
189
- grantType,
190
- callbackUrl,
191
- registrationRef: integration.authentication.registrationRef
192
- };
193
- }
194
-
195
- async function apiKeyFor(integration, provider) {
196
- if (!integration.authentication.secretRef && provider.apiKeySecretOptional) return "";
197
- try {
198
- const value = await resolveReference(integration.authentication.secretRef);
199
- if (typeof value !== "string" || (!provider.apiKeySecretOptional && !value.trim()) || value.trim() === "MISSING" || /[\r\n]/u.test(value)) throw new Error("Invalid key.");
200
- return value;
201
- } catch {
202
- throw new ConnectorError("connector_binding_missing", "The API key binding is missing or invalid.", { messageCode: "connectors.connection.apiKeyBindingMissingInvalid" });
203
- }
204
- }
205
-
206
- async function serviceAccountCredentialFor(integration) {
207
- try {
208
- const credential = await resolveReference(integration.authentication.secretRef);
209
- if (typeof credential !== "string" || !credential.trim() || credential.trim() === "MISSING" || credential.length > 65_536 || credential.includes("\0")) throw new Error("Invalid credential.");
210
- return { credential, fingerprint: createHash("sha256").update(credential).digest("hex") };
211
- } catch {
212
- throw new ConnectorError("connector_binding_missing", "The service-account credential binding is missing or invalid.", { messageCode: "connectors.connection.serviceAccountCredentialBindingMissing" });
213
- }
214
- }
215
-
216
- // An Env refresh token seeds the shared connection; the app then refreshes
217
- // and stores rotated tokens itself, and reseeds when the value or the OAuth
218
- // client changes.
219
- async function envRefreshTokenFor(integration) {
220
- try {
221
- const value = await resolveReference(integration.authentication.refreshTokenRef);
222
- const refreshToken = typeof value === "string" ? value.trim() : "";
223
- if (!refreshToken || refreshToken === "MISSING" || /\s/u.test(refreshToken)) throw new Error("Invalid token.");
224
- return { refreshToken, fingerprint: createHash("sha256").update(refreshToken).digest("hex") };
225
- } catch {
226
- throw new ConnectorError("connector_binding_missing", "The refresh token binding is missing or invalid.", { messageCode: "connectors.connection.refreshTokenBindingMissingInvalid" });
227
- }
228
- }
229
-
230
133
  function envSeedIsCurrent(connection, seed, registration) {
231
134
  return connection?.seedFingerprint === seed.fingerprint && connection.clientId === registration.client.client_id &&
232
135
  connection.registrationRef === registration.registrationRef &&
@@ -238,44 +141,18 @@ function createConnectionService({
238
141
  integrationId, provider: provider.id, registrationRef: registration.registrationRef,
239
142
  grantType: registration.grantType, clientId: registration.client.client_id,
240
143
  tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod, status: "connected", source: "env",
241
- seedFingerprint: seed.fingerprint, verifiedAt: now(), settings: structuredClone(integration.settings || {}),
144
+ seedFingerprint: seed.fingerprint, verifiedAt: now(), settings: structuredClone(integration.settings),
242
145
  ...(provider.refreshRequiresRedirectUri ? { callbackUrl: registration.callbackUrl } : {}),
243
146
  tokens: { accessToken: null, refreshToken: seed.refreshToken, expiresAt: 0 },
244
147
  grantedScopes: [...integration.scopes]
245
148
  };
246
149
  }
247
150
 
248
- async function serviceAccountGrant(integration, provider, binding, signal) {
249
- if (typeof provider.serviceAccountGrant !== "function") {
250
- throw new ConnectorError("connector_mode_unavailable", "This provider does not implement service-account authorization.", { messageCode: "connectors.connection.providerDoesNotImplementService" });
251
- }
252
- const options = requestOptions(signal, provider.requestTimeoutMs);
253
- options.signal.throwIfAborted();
254
- let response;
255
- try {
256
- response = await provider.serviceAccountGrant({
257
- credential: binding.credential, settings: structuredClone(integration.settings || {}),
258
- scopes: [...integration.scopes], fetchImpl, signal: options.signal, now: now()
259
- });
260
- } catch (error) {
261
- options.signal.throwIfAborted();
262
- throw error;
263
- }
264
- options.signal.throwIfAborted();
265
- if (typeof response?.access_token !== "string" || !response.access_token || /\s/u.test(response.access_token) ||
266
- response.token_type?.toLowerCase?.() !== "bearer" ||
267
- !Number.isFinite(response.expires_in) || response.expires_in <= 0 || response.expires_in > 86_400 ||
268
- response.refresh_token !== undefined || (response.scope !== undefined && typeof response.scope !== "string")) {
269
- throw new ConnectorError("connector_response_invalid", "The provider returned an invalid service-account token.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedInvalidServiceAccount" });
270
- }
271
- const grant = tokensFrom(response, null, integration.scopes, provider.scopeSeparator, provider);
272
- grant.grantedScopes = grant.grantedScopes.filter((scope) => integration.scopes.includes(scope));
273
- return { ...grant, credentialFingerprint: binding.fingerprint };
274
- }
275
-
151
+ /** @param {import("./types.js").ConnectRequest} request */
276
152
  async function connectServiceAccount({ context, integrationId, verificationInput = {}, signal }) {
277
153
  verificationInput = structuredClone(verificationInput);
278
154
  const { owner, integration, provider } = await access(context, integrationId, "connect");
155
+ // readable-check: allow method-probe - optional operation: serviceAccountGrant is an optional hook of the provider definition
279
156
  if (integration.authentication.method !== "service-account" || typeof provider.serviceAccountGrant !== "function") {
280
157
  throw new ConnectorError("connector_mode_unavailable", "Select service-account credentials to verify this connection.", { messageCode: "connectors.connection.selectServiceAccountCredentialsVerify" });
281
158
  }
@@ -283,145 +160,24 @@ function createConnectionService({
283
160
  requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE, VERIFICATION_SCOPE_CODE);
284
161
  return store.withConnection({ owner, integrationId }, async ({ save }) => {
285
162
  try {
286
- const binding = await serviceAccountCredentialFor(integration);
163
+ const binding = await connectionCredentials.serviceAccountCredentialFor(integration);
287
164
  const connection = {
288
165
  integrationId, provider: provider.id, method: "service-account", secretRef: integration.authentication.secretRef,
289
- requestedScopes: [...integration.scopes], settings: structuredClone(integration.settings || {}),
290
- status: "connected", verifiedAt: now(), ...await serviceAccountGrant(integration, provider, binding, signal)
166
+ requestedScopes: [...integration.scopes], settings: structuredClone(integration.settings),
167
+ status: "connected", verifiedAt: now(), ...await providerRequests.serviceAccountGrant(integration, provider, binding, signal)
291
168
  };
292
- await request(provider, provider.checkOperation, verificationInput, connection, signal);
169
+ await providerRequests.request(provider, provider.checkOperation, verificationInput, connection, signal);
293
170
  await save(connection);
294
171
  return publicConnection(connection);
295
172
  } catch (error) { throw providerError(error); }
296
173
  });
297
174
  }
298
175
 
299
- function requestOptions(signal, timeoutMs = 15_000, tokenRequestEncoding) {
300
- const timeout = AbortSignal.timeout(timeoutMs);
301
- return {
302
- [oauth.customFetch]: tokenRequestEncoding === "json" ? (address, options) => {
303
- const headers = new Headers(options.headers);
304
- headers.set("Content-Type", "application/json");
305
- return fetchImpl(address, { ...options, headers,
306
- body: JSON.stringify(Object.fromEntries(new URLSearchParams(options.body))) });
307
- } : fetchImpl,
308
- signal: signal ? AbortSignal.any([signal, timeout]) : timeout
309
- };
310
- }
311
-
312
- async function request(provider, operationId, input, connection, signal) {
313
- const operation = provider.operations[operationId];
314
- if (!operation || !Object.hasOwn(provider.operations, operationId)) {
315
- throw new ConnectorError("connector_operation_unknown", "This provider operation is not available.", { messageCode: "connectors.connection.providerOperationNotAvailable" });
316
- }
317
- if (["oauth2", "service-account"].includes(connection.method || "oauth2")) {
318
- requireOperationScope(operation, connection.grantedScopes, "Connect again with the permission required for this operation.", { messageCode: "connectors.connection.connectAgainPermissionRequiredOperation" });
319
- }
320
- const descriptor = operationRequest(operation, input, connection.settings, connection.providerData);
321
- const url = new URL(descriptor.url);
322
- const origins = typeof provider.apiOrigins === "function" ? provider.apiOrigins(connection.settings || {}, connection.providerData) : provider.apiOrigins;
323
- if (url.protocol !== "https:" || url.username || url.password || !origins.includes(url.origin)) {
324
- throw new ConnectorError("connector_destination_invalid", "The provider operation has an invalid destination.", { messageCode: "connectors.connection.providerOperationInvalidDestination" });
325
- }
326
- if (connection.method === "api-key" && provider.apiKey.pathPrefix) {
327
- const prefix = provider.apiKey.pathPrefix(connection.apiKey);
328
- if (typeof prefix !== "string" || !prefix.startsWith("/") || prefix.startsWith("//") ||
329
- /[?#\\]/.test(prefix) || prefix.split("/").some((part) => /^(?:\.|%2e){1,2}$/i.test(part))) {
330
- throw new ConnectorError("connector_request_invalid", "This provider has an invalid credential path.", { messageCode: "connectors.connection.providerInvalidCredentialPath" });
331
- }
332
- // A pathname assignment cannot replace the already checked origin.
333
- url.pathname = prefix + url.pathname;
334
- }
335
- if (connection.method === "api-key" && provider.apiKey.queryParameter) {
336
- url.searchParams.set(provider.apiKey.queryParameter, connection.apiKey);
337
- }
338
- let body = descriptor.body;
339
- if (connection.method === "api-key" && provider.apiKey.bodyParameter) {
340
- if (!body || typeof body !== "object" || Array.isArray(body) || ["GET", "HEAD"].includes(descriptor.method)) {
341
- throw new ConnectorError("connector_request_invalid", "This provider requires a JSON request body.", { messageCode: "connectors.connection.providerRequiresJsonRequestBody" });
342
- }
343
- body = { ...body, [provider.apiKey.bodyParameter]: connection.apiKey };
344
- }
345
- const options = {
346
- method: descriptor.method,
347
- body,
348
- headers: { ...descriptor.headers, Accept: "application/json", ...(connection.method === "api-key"
349
- ? provider.apiKey.headers?.(connection.apiKey, connection.settings || {})
350
- : connection.method === "none" ? {} : {
351
- ...provider.oauthHeaders?.({ clientId: connection.clientId }), Authorization: `Bearer ${connection.tokens.accessToken}`
352
- }) },
353
- redirect: "error",
354
- signal: requestOptions(signal, provider.requestTimeoutMs).signal
355
- };
356
- let result;
357
- try {
358
- result = provider.exchange
359
- ? await provider.exchange(url.href, options, { fetchImpl, request: http.request, resolveReference,
360
- settings: connection.settings || {}, apiKey: connection.method === "api-key" ? connection.apiKey : undefined })
361
- : await http.request(url.href, options);
362
- } catch (error) {
363
- // Transports can wrap cancellation in a network or protocol error.
364
- options.signal.throwIfAborted();
365
- throw error;
366
- }
367
- // The validator also receives the operation's own request, so it can check the provider answered what was asked.
368
- if (typeof operation.validateResult === "function" && !operation.validateResult(result, descriptor)) {
369
- throw new ConnectorError("connector_response_invalid", "The provider returned an unexpected response.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedUnexpectedResponse" });
370
- }
371
- return result;
372
- }
373
-
374
- function tokensFrom(response, previous, requestedScopes, scopeSeparator = " ", provider) {
375
- return {
376
- ...(provider?.dataFromTokenResponse ? { providerData: provider.dataFromTokenResponse(response, previous?.providerData) } : {}),
377
- tokens: {
378
- accessToken: response.access_token,
379
- refreshToken: response.refresh_token || previous?.tokens.refreshToken || null,
380
- expiresAt: response.expires_in === undefined ? null : now() + response.expires_in * 1000
381
- },
382
- grantedScopes: response.scope === undefined
383
- ? [...(previous?.grantedScopes || requestedScopes)]
384
- : response.scope.split(scopeSeparator).map((scope) => scope.trim()).filter(Boolean)
385
- };
386
- }
387
-
388
- async function verifyOAuthConnection(provider, connection, verificationInput, requestedScopes, signal) {
389
- const verification = await request(provider, provider.checkOperation, verificationInput, connection, signal);
390
- if (provider.accountLabelFromVerification) {
391
- const label = provider.accountLabelFromVerification(verification);
392
- if (typeof label !== "string" || !label.trim() || label.length > 256 || /[\p{Cc}\p{Cf}]/u.test(label)) {
393
- throw new ConnectorError("connector_response_invalid", "The provider returned an invalid account label.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedInvalidAccountLabel" });
394
- }
395
- connection.accountLabel = label;
396
- }
397
- if (!provider.grantedScopesFromVerification) return;
398
- const scopes = provider.grantedScopesFromVerification(verification, { clientId: connection.clientId });
399
- if (!Array.isArray(scopes) || scopes.some((scope) => typeof scope !== "string" || !scope)) {
400
- throw new ConnectorError("connector_response_invalid", "The provider returned an invalid permission grant.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedInvalidPermissionGrant" });
401
- }
402
- connection.grantedScopes = connection.grantedScopes.filter((scope) => requestedScopes.includes(scope) && scopes.includes(scope));
403
- requireOperationScope(provider.operations[provider.checkOperation], connection.grantedScopes, "Connect again with the permission required for verification.", { messageCode: "connectors.connection.connectAgainPermissionRequiredVerification" });
404
- }
405
-
406
- async function clientCredentialsGrant(registration, provider, settings, scopes, signal) {
407
- const response = await oauth.clientCredentialsGrantRequest(
408
- registration.oauth, registration.client, registration.clientAuth,
409
- { scope: scopes.join(provider.scopeSeparator || " "), ...(registration.resource ? { resource: registration.resource } : {}) },
410
- requestOptions(signal)
411
- );
412
- const result = await oauth.processClientCredentialsResponse(registration.oauth, registration.client,
413
- provider.normalizeTokenResponse ? await provider.normalizeTokenResponse(response, { settings, grantType: "client_credentials" }) : response);
414
- const grant = tokensFrom(result, null, scopes, provider.scopeSeparator, provider);
415
- // Client credentials renew with the application's secret, never a user refresh token.
416
- grant.tokens.refreshToken = null;
417
- grant.grantedScopes = grant.grantedScopes.filter((scope) => scopes.includes(scope));
418
- return grant;
419
- }
420
-
176
+ /** @param {import("./types.js").ConnectRequest} request */
421
177
  async function connectClientCredentials({ context, integrationId, verificationInput = {}, signal }) {
422
178
  verificationInput = structuredClone(verificationInput);
423
179
  const { owner, integration, provider } = await access(context, integrationId, "connect");
424
- const registration = await registrationFor(integration, provider);
180
+ const registration = await connectionCredentials.registrationFor(integration, provider);
425
181
  if (registration.grantType !== "client_credentials") {
426
182
  throw new ConnectorError("connector_mode_unavailable", "Select client credentials to connect a service account.", { messageCode: "connectors.connection.selectClientCredentialsConnectService" });
427
183
  }
@@ -433,16 +189,17 @@ function createConnectionService({
433
189
  integrationId, provider: provider.id, registrationRef: registration.registrationRef,
434
190
  clientId: registration.client.client_id, tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod,
435
191
  grantType: registration.grantType, requestedScopes: [...integration.scopes],
436
- settings: structuredClone(integration.settings || {}), status: "connected", verifiedAt: now(),
437
- ...await clientCredentialsGrant(registration, provider, integration.settings || {}, integration.scopes, signal)
192
+ settings: structuredClone(integration.settings), status: "connected", verifiedAt: now(),
193
+ ...await providerRequests.clientCredentialsGrant(registration, provider, integration.settings, integration.scopes, signal)
438
194
  };
439
- await verifyOAuthConnection(provider, connection, verificationInput, integration.scopes, signal);
195
+ await providerRequests.verifyOAuthConnection(provider, connection, verificationInput, integration.scopes, signal);
440
196
  await save(connection);
441
197
  return publicConnection(connection);
442
198
  } catch (error) { throw providerError(error); }
443
199
  });
444
200
  }
445
201
 
202
+ /** @param {import("./types.js").ConnectRequest} request */
446
203
  async function beginAuthorization({ context, integrationId, verificationInput = {}, signal }) {
447
204
  const { owner, integration, provider } = await access(context, integrationId, "connect");
448
205
  if (integration.authentication.refreshTokenRef) {
@@ -450,7 +207,7 @@ function createConnectionService({
450
207
  }
451
208
  // Resource-specific providers validate the first document ID before opening consent.
452
209
  operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
453
- const registration = await registrationFor(integration, provider);
210
+ const registration = await connectionCredentials.registrationFor(integration, provider);
454
211
  if (registration.grantType !== "authorization_code") {
455
212
  throw new ConnectorError("connector_mode_unavailable", "Service accounts connect without browser consent.", { messageCode: "connectors.connection.serviceAccountsConnectWithoutBrowser" });
456
213
  }
@@ -463,8 +220,9 @@ function createConnectionService({
463
220
  url.searchParams.set(provider.oauthClientIdParameter || "client_id", registration.client.client_id);
464
221
  url.searchParams.set("redirect_uri", registration.callbackUrl);
465
222
  url.searchParams.set("response_type", "code");
466
- const scopeParameter = provider.authorizationScopeParameter?.(integration.settings || {}) || "scope";
467
- if (integration.scopes.length) url.searchParams.set(scopeParameter, integration.scopes.join(provider.scopeSeparator || " "));
223
+ // readable-check: allow method-probe - optional operation: authorizationScopeParameter is an optional hook of the provider definition
224
+ const scopeParameter = (typeof provider.authorizationScopeParameter === "function" && provider.authorizationScopeParameter(integration.settings)) || "scope";
225
+ if (integration.scopes.length) url.searchParams.set(scopeParameter, integration.scopes.join(provider.scopeSeparator));
468
226
  url.searchParams.set("state", state);
469
227
  if (codeVerifier !== null) {
470
228
  url.searchParams.set("code_challenge", await oauth.calculatePKCECodeChallenge(codeVerifier));
@@ -477,16 +235,17 @@ function createConnectionService({
477
235
  registrationRef: registration.registrationRef,
478
236
  grantType: registration.grantType,
479
237
  callbackUrl: registration.callbackUrl,
480
- scopes: [...integration.scopes], settings: structuredClone(integration.settings || {}),
238
+ scopes: [...integration.scopes], settings: structuredClone(integration.settings),
481
239
  verificationInput: structuredClone(verificationInput), authorizationUrl: url.href, codeVerifier, expiresAt
482
240
  }));
483
241
  return { authorizationUrl: url.href, expiresAt, callbackUrl: registration.callbackUrl };
484
242
  }
485
243
 
244
+ /** @param {import("./types.js").IntegrationRequest} request */
486
245
  async function resumeAuthorization({ context, integrationId }) {
487
246
  const { owner, integration, provider } = await access(context, integrationId, "connect");
488
247
  if (integration.authentication.method !== "oauth2") return null;
489
- const registration = await registrationFor(integration, provider);
248
+ const registration = await connectionCredentials.registrationFor(integration, provider);
490
249
  if (registration.grantType !== "authorization_code") return null;
491
250
  return store.withConnection({ owner, integrationId }, async ({ latestAttempt }) => {
492
251
  const attempt = await latestAttempt({ after: now() });
@@ -496,14 +255,15 @@ function createConnectionService({
496
255
  attempt.callbackUrl !== registration.callbackUrl ||
497
256
  attempt.tokenEndpointAuthMethod !== registration.tokenEndpointAuthMethod ||
498
257
  !isDeepStrictEqual(attempt.scopes, integration.scopes) ||
499
- !isDeepStrictEqual(attempt.settings, integration.settings || {})) return null;
258
+ !isDeepStrictEqual(attempt.settings, integration.settings)) return null;
500
259
  return { authorizationUrl: attempt.authorizationUrl, expiresAt: attempt.expiresAt, callbackUrl: registration.callbackUrl };
501
260
  });
502
261
  }
503
262
 
263
+ /** @param {import("./types.js").ProviderRequest & { callbackUrl: string }} request */
504
264
  async function completeAuthorization({ context, integrationId, callbackUrl, signal }) {
505
265
  const { owner, integration, provider } = await access(context, integrationId, "connect");
506
- const registration = await registrationFor(integration, provider);
266
+ const registration = await connectionCredentials.registrationFor(integration, provider);
507
267
  if (registration.grantType !== "authorization_code") {
508
268
  throw new ConnectorError("connector_mode_unavailable", "Service accounts do not use an authorization callback.", { messageCode: "connectors.connection.serviceAccountsDoNotUse" });
509
269
  }
@@ -527,7 +287,7 @@ function createConnectionService({
527
287
  registrationGrantType(attempt) !== registration.grantType ||
528
288
  registrationClientAuthentication(attempt) !== registration.tokenEndpointAuthMethod ||
529
289
  attempt.callbackUrl !== registration.callbackUrl || JSON.stringify(attempt.scopes) !== JSON.stringify(integration.scopes) ||
530
- !isDeepStrictEqual(attempt.settings || {}, integration.settings || {})) {
290
+ !isDeepStrictEqual(attempt.settings || {}, integration.settings)) {
531
291
  throw new ConnectorError("connector_attempt_invalid", "The authorization attempt is invalid or has expired.", { messageCode: "connectors.connection.authorizationAttemptInvalidExpired" });
532
292
  }
533
293
  const parameters = oauth.validateAuthResponse(registration.oauth, registration.client, url, state);
@@ -537,26 +297,26 @@ function createConnectionService({
537
297
  if (values.length !== 1) {
538
298
  throw new ConnectorError("connector_response_invalid", "The provider did not return an unambiguous permission grant.", { statusCode: 502, messageCode: "connectors.connection.ambiguousPermissionGrant" });
539
299
  }
540
- const granted = new Set(values[0].split(provider.scopeSeparator || " ").map((scope) => scope.trim()).filter(Boolean));
300
+ const granted = new Set(values[0].split(provider.scopeSeparator).map((scope) => scope.trim()).filter(Boolean));
541
301
  fallbackScopes = attempt.scopes.filter((scope) => granted.has(scope));
542
302
  }
543
303
  const response = await oauth.authorizationCodeGrantRequest(
544
304
  registration.oauth, registration.client, registration.clientAuth,
545
- parameters, registration.callbackUrl, attempt.codeVerifier === null ? oauth.nopkce : attempt.codeVerifier, { ...requestOptions(signal, undefined, provider.tokenRequestEncoding),
305
+ parameters, registration.callbackUrl, attempt.codeVerifier === null ? oauth.nopkce : attempt.codeVerifier, { ...providerRequests.tokenRequestOptions(signal, provider),
546
306
  ...(registration.resource ? { additionalParameters: { resource: registration.resource } } : {}) }
547
307
  );
548
308
  const result = await oauth.processAuthorizationCodeResponse(registration.oauth, registration.client,
549
- provider.normalizeTokenResponse ? await provider.normalizeTokenResponse(response, { settings: integration.settings || {}, grantType: "authorization_code" }) : response);
309
+ await tokenResponse(provider, response, { settings: integration.settings, grantType: "authorization_code" }));
550
310
  const connection = {
551
311
  integrationId, provider: provider.id, registrationRef: registration.registrationRef,
552
312
  grantType: registration.grantType,
553
313
  clientId: registration.client.client_id, tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod, status: "connected", verifiedAt: now(),
554
- settings: structuredClone(integration.settings || {}),
314
+ settings: structuredClone(integration.settings),
555
315
  ...(provider.refreshRequiresRedirectUri ? { callbackUrl: registration.callbackUrl } : {}),
556
- ...tokensFrom(result, null, fallbackScopes, provider.scopeSeparator, provider)
316
+ ...providerRequests.tokensFrom(result, null, fallbackScopes, provider)
557
317
  };
558
318
  connection.grantedScopes = connection.grantedScopes.filter((scope) => integration.scopes.includes(scope));
559
- await verifyOAuthConnection(provider, connection, attempt.verificationInput || {}, attempt.scopes, signal);
319
+ await providerRequests.verifyOAuthConnection(provider, connection, attempt.verificationInput || {}, attempt.scopes, signal);
560
320
  await save(connection);
561
321
  return { result: publicConnection(connection) };
562
322
  } catch (error) {
@@ -568,10 +328,16 @@ function createConnectionService({
568
328
  return outcome.result;
569
329
  }
570
330
 
331
+ /**
332
+ * @param {import("./types.js").ConnectRequest} request
333
+ * @param {"api-key" | "none"} method
334
+ */
571
335
  async function verifyDirectConnection({ context, integrationId, verificationInput = {}, signal }, method) {
572
336
  const { owner, integration, provider } = await access(context, integrationId, "connect");
573
337
  if (integration.authentication.method !== method || (method === "api-key" &&
338
+ // readable-check: allow method-probe - input validation: an API-key provider definition places its key with headers(), a parameter or pathPrefix()
574
339
  (typeof provider.apiKey?.headers !== "function" && !provider.apiKey?.queryParameter &&
340
+ // readable-check: allow method-probe - input validation: an API-key provider definition places its key with headers(), a parameter or pathPrefix()
575
341
  !provider.apiKey?.bodyParameter && typeof provider.apiKey?.pathPrefix !== "function"))) {
576
342
  throw new ConnectorError("connector_mode_unavailable", "Select the matching credential mode to verify this connection.", { messageCode: "connectors.connection.selectMatchingCredentialModeVerify" });
577
343
  }
@@ -580,11 +346,11 @@ function createConnectionService({
580
346
  const connection = {
581
347
  integrationId, provider: provider.id, method,
582
348
  ...(method === "api-key" && integration.authentication.secretRef ? { secretRef: integration.authentication.secretRef } : {}),
583
- grantedScopes: [], status: "connected", verifiedAt: now(), settings: structuredClone(integration.settings || {})
349
+ grantedScopes: [], status: "connected", verifiedAt: now(), settings: structuredClone(integration.settings)
584
350
  };
585
351
  try {
586
- const credentials = method === "api-key" ? { ...connection, apiKey: await apiKeyFor(integration, provider) } : connection;
587
- await request(provider, provider.checkOperation, verificationInput, credentials, signal);
352
+ const credentials = method === "api-key" ? { ...connection, apiKey: await connectionCredentials.apiKeyFor(integration, provider) } : connection;
353
+ await providerRequests.request(provider, provider.checkOperation, verificationInput, credentials, signal);
588
354
  if (method === "api-key") connection.credentialFingerprint = createHash("sha256").update(credentials.apiKey).digest("hex");
589
355
  await save(connection);
590
356
  return publicConnection(connection);
@@ -592,9 +358,12 @@ function createConnectionService({
592
358
  });
593
359
  }
594
360
 
361
+ /** @param {import("./types.js").ConnectRequest} input */
595
362
  const connectApiKey = (input) => verifyDirectConnection(input, "api-key");
363
+ /** @param {import("./types.js").ConnectRequest} input */
596
364
  const connectWithoutCredentials = (input) => verifyDirectConnection(input, "none");
597
365
 
366
+ /** @param {import("./types.js").ProviderRequest & { operation: string, input?: any }} request */
598
367
  async function invoke({ context, integrationId, operation, input = {}, signal }) {
599
368
  // Authorize and execute the same input even if the caller changes its object while awaiting policy.
600
369
  input = structuredClone(input);
@@ -606,8 +375,8 @@ function createConnectionService({
606
375
  const usesApiKey = integration.authentication.method === "api-key";
607
376
  const usesServiceAccount = integration.authentication.method === "service-account";
608
377
  const usesOAuth = integration.authentication.method === "oauth2";
609
- const registration = usesOAuth ? await registrationFor(integration, provider) : null;
610
- const seed = integration.authentication.refreshTokenRef ? await envRefreshTokenFor(integration) : null;
378
+ const registration = usesOAuth ? await connectionCredentials.registrationFor(integration, provider) : null;
379
+ const seed = integration.authentication.refreshTokenRef ? await connectionCredentials.envRefreshTokenFor(integration) : null;
611
380
  const outcome = await store.withConnection({ owner, integrationId }, async ({ connection, save }) => {
612
381
  if (seed && !envSeedIsCurrent(connection, seed, registration)) {
613
382
  connection = envSeededConnection(integrationId, integration, provider, registration, seed);
@@ -620,39 +389,39 @@ function createConnectionService({
620
389
  }
621
390
  try {
622
391
  if (usesServiceAccount) {
623
- const binding = await serviceAccountCredentialFor(integration);
624
- if (binding.fingerprint !== connection.credentialFingerprint || connection.tokens.expiresAt <= now() + (provider.tokenRefreshLeewayMs ?? 30_000)) {
625
- connection = { ...connection, ...await serviceAccountGrant(integration, provider, binding, signal) };
392
+ const binding = await connectionCredentials.serviceAccountCredentialFor(integration);
393
+ if (binding.fingerprint !== connection.credentialFingerprint || connection.tokens.expiresAt <= now() + provider.tokenRefreshLeewayMs) {
394
+ connection = { ...connection, ...await providerRequests.serviceAccountGrant(integration, provider, binding, signal) };
626
395
  await save(connection);
627
396
  }
628
397
  }
629
- if (usesOAuth && connection.tokens.expiresAt !== null && connection.tokens.expiresAt <= now() + (provider.tokenRefreshLeewayMs ?? 30_000)) {
398
+ if (usesOAuth && connection.tokens.expiresAt !== null && connection.tokens.expiresAt <= now() + provider.tokenRefreshLeewayMs) {
630
399
  let grant;
631
400
  if (registration.grantType === "client_credentials") {
632
- grant = await clientCredentialsGrant(registration, provider, integration.settings || {}, connection.grantedScopes, signal);
401
+ grant = await providerRequests.clientCredentialsGrant(registration, provider, integration.settings, connection.grantedScopes, signal);
633
402
  } else {
634
403
  if (!connection.tokens.refreshToken) {
635
404
  throw new ConnectorError("connector_reconnect_required", "Connect this account again.", { statusCode: 401, messageCode: "connectors.connection.connectAccountAgain" });
636
405
  }
637
406
  const response = await oauth.refreshTokenGrantRequest(
638
407
  registration.oauth, registration.client, registration.clientAuth,
639
- connection.tokens.refreshToken, { ...requestOptions(signal, undefined, provider.tokenRequestEncoding),
408
+ connection.tokens.refreshToken, { ...providerRequests.tokenRequestOptions(signal, provider),
640
409
  additionalParameters: {
641
410
  ...(registration.resource ? { resource: registration.resource } : {}),
642
411
  ...(provider.refreshRequiresRedirectUri ? { redirect_uri: connection.callbackUrl } : {})
643
412
  } }
644
413
  );
645
414
  const refreshed = await oauth.processRefreshTokenResponse(registration.oauth, registration.client,
646
- provider.normalizeTokenResponse ? await provider.normalizeTokenResponse(response, { settings: integration.settings || {}, grantType: "refresh_token" }) : response);
647
- grant = tokensFrom(refreshed, connection, integration.scopes, provider.scopeSeparator, provider);
415
+ await tokenResponse(provider, response, { settings: integration.settings, grantType: "refresh_token" }));
416
+ grant = providerRequests.tokensFrom(refreshed, connection, integration.scopes, provider);
648
417
  }
649
418
  grant.grantedScopes = grant.grantedScopes.filter((scope) =>
650
419
  connection.grantedScopes.includes(scope) && integration.scopes.includes(scope));
651
420
  connection = { ...connection, ...grant };
652
421
  await save(connection);
653
422
  }
654
- const credentials = usesApiKey ? { ...connection, apiKey: await apiKeyFor(integration, provider) } : connection;
655
- const result = await request(provider, operation, input, credentials, signal);
423
+ const credentials = usesApiKey ? { ...connection, apiKey: await connectionCredentials.apiKeyFor(integration, provider) } : connection;
424
+ const result = await providerRequests.request(provider, operation, input, credentials, signal);
656
425
  await save({ ...connection, verifiedAt: now(),
657
426
  ...(usesApiKey ? { credentialFingerprint: createHash("sha256").update(credentials.apiKey).digest("hex") } : {})
658
427
  });
@@ -671,6 +440,7 @@ function createConnectionService({
671
440
  return outcome.result;
672
441
  }
673
442
 
443
+ /** @param {import("./types.js").IntegrationRequest} request */
674
444
  async function status({ context, integrationId }) {
675
445
  const { owner, integration, provider } = await access(context, integrationId, "status");
676
446
  // Compare against the configured registration without resolving its secrets.
@@ -686,10 +456,10 @@ function createConnectionService({
686
456
  let callbackUrl;
687
457
  try {
688
458
  if (integration.authentication.method === "oauth2") {
689
- const resolved = await registrationFor(integration, provider);
459
+ const resolved = await connectionCredentials.registrationFor(integration, provider);
690
460
  callbackUrl = resolved.callbackUrl;
691
461
  if (integration.authentication.refreshTokenRef) {
692
- const seed = await envRefreshTokenFor(integration);
462
+ const seed = await connectionCredentials.envRefreshTokenFor(integration);
693
463
  // A new value is used on the next call; until then it reads as connected from Env.
694
464
  if (!envSeedIsCurrent(connection, seed, resolved)) {
695
465
  return { status: "connected", source: "env", provider: provider.id, integrationId, grantedScopes: [...integration.scopes], callbackUrl };
@@ -699,11 +469,11 @@ function createConnectionService({
699
469
  return publicConnection({ ...connection, status: "reconnect-required" });
700
470
  }
701
471
  } else if (integration.authentication.method === "api-key") {
702
- const key = await apiKeyFor(integration, provider);
472
+ const key = await connectionCredentials.apiKeyFor(integration, provider);
703
473
  if (connection && connection.credentialFingerprint !== createHash("sha256").update(key).digest("hex")) {
704
474
  return publicConnection({ ...connection, status: "reconnect-required" });
705
475
  }
706
- } else if (integration.authentication.method === "service-account") await serviceAccountCredentialFor(integration);
476
+ } else if (integration.authentication.method === "service-account") await connectionCredentials.serviceAccountCredentialFor(integration);
707
477
  } catch (error) {
708
478
  if (!["connector_binding_missing", "connector_callback_invalid"].includes(error.code)) throw error;
709
479
  if (!connection) return { status: "unconfigured", configurationError: error.code };
@@ -713,6 +483,7 @@ function createConnectionService({
713
483
  });
714
484
  }
715
485
 
486
+ /** @param {import("./types.js").IntegrationRequest} request */
716
487
  async function disconnect({ context, integrationId }) {
717
488
  const { owner, integration } = await access(context, integrationId, "disconnect");
718
489
  if (integration.authentication.refreshTokenRef) {
@@ -722,13 +493,27 @@ function createConnectionService({
722
493
  return { status: "disconnected" };
723
494
  }
724
495
 
496
+ /** @param {import("./types.js").IntegrationRequest & { state: string }} request */
725
497
  async function cancelAuthorization({ context, integrationId, state }) {
726
498
  const { owner } = await access(context, integrationId, "connect", undefined, false);
727
499
  await store.withConnection({ owner, integrationId }, async ({ consumeAttempt }) => consumeAttempt(state));
728
500
  return { status: "cancelled" };
729
501
  }
730
502
 
731
- return Object.freeze({ beginAuthorization, resumeAuthorization, completeAuthorization, cancelAuthorization, connectClientCredentials, connectServiceAccount, connectApiKey, connectWithoutCredentials, invoke, status, disconnect, authorizeAssistantAction });
503
+ return Object.freeze({
504
+ beginAuthorization,
505
+ resumeAuthorization,
506
+ completeAuthorization,
507
+ cancelAuthorization,
508
+ connectClientCredentials,
509
+ connectServiceAccount,
510
+ connectApiKey,
511
+ connectWithoutCredentials,
512
+ invoke,
513
+ status,
514
+ disconnect,
515
+ authorizeAssistantAction
516
+ });
732
517
  }
733
518
 
734
519
  export { createConnectionService };