@jskit-ai/connectors-core 0.2.2 → 0.2.4

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,33 +1,44 @@
1
1
  import { CompactEncrypt, compactDecrypt } from "jose";
2
2
  import { ConnectorError } from "./errors.js";
3
3
 
4
+ /**
5
+ * Seals values with the active key and opens them with any named key, each bound to its record.
6
+ * @param {object} options
7
+ * @param {Record<string, Uint8Array>} options.keys Named 32-byte keys.
8
+ * @param {string} options.activeKeyId The key that seals new values.
9
+ */
4
10
  function createCredentialProtection({ keys, activeKeyId }) {
5
- const keyring = new Map(Object.entries(keys || {}));
11
+ const keyring = new Map(Object.entries(keys));
6
12
  if (!keyring.has(activeKeyId) || [...keyring.values()].some((key) => !(key instanceof Uint8Array) || key.length !== 32)) {
7
13
  throw new TypeError("Credential protection requires named 32-byte keys and an active key ID.");
8
14
  }
9
15
  const encoder = new TextEncoder();
10
16
  const decoder = new TextDecoder();
11
- return Object.freeze({
12
- async seal(value, binding) {
13
- return new CompactEncrypt(encoder.encode(JSON.stringify({ binding, value })))
14
- .setProtectedHeader({ alg: "dir", enc: "A256GCM", kid: activeKeyId })
15
- .encrypt(keyring.get(activeKeyId));
16
- },
17
- async open(ciphertext, binding) {
18
- try {
19
- const { plaintext } = await compactDecrypt(ciphertext, (header) => {
20
- const key = keyring.get(header.kid);
21
- if (!key) throw new Error("Unknown key.");
22
- return key;
23
- }, { keyManagementAlgorithms: ["dir"], contentEncryptionAlgorithms: ["A256GCM"] });
24
- const envelope = JSON.parse(decoder.decode(plaintext));
25
- if (envelope.binding !== binding) throw new Error("Incorrect record binding.");
26
- return envelope.value;
27
- } catch {
28
- throw new ConnectorError("connector_credentials_unavailable", "Stored credentials could not be opened.", { statusCode: 500 });
29
- }
17
+
18
+ async function seal(value, binding) {
19
+ return new CompactEncrypt(encoder.encode(JSON.stringify({ binding, value })))
20
+ .setProtectedHeader({ alg: "dir", enc: "A256GCM", kid: activeKeyId })
21
+ .encrypt(keyring.get(activeKeyId));
22
+ }
23
+
24
+ async function open(ciphertext, binding) {
25
+ try {
26
+ const { plaintext } = await compactDecrypt(ciphertext, (header) => {
27
+ const key = keyring.get(header.kid);
28
+ if (!key) throw new Error("Unknown key.");
29
+ return key;
30
+ }, { keyManagementAlgorithms: ["dir"], contentEncryptionAlgorithms: ["A256GCM"] });
31
+ const envelope = JSON.parse(decoder.decode(plaintext));
32
+ if (envelope.binding !== binding) throw new Error("Incorrect record binding.");
33
+ return envelope.value;
34
+ } catch {
35
+ throw new ConnectorError("connector_credentials_unavailable", "Stored credentials could not be opened.", { statusCode: 500 });
30
36
  }
37
+ }
38
+
39
+ return Object.freeze({
40
+ seal,
41
+ open
31
42
  });
32
43
  }
33
44
 
@@ -1,6 +1,15 @@
1
1
  import { ConnectorError } from "./errors.js";
2
2
 
3
- function createEnvironmentReferenceResolver(env = process.env) {
3
+ /**
4
+ * Resolves `env:NAME` references from the application's environment; an
5
+ * absent, blank or MISSING value is a missing binding.
6
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeEnv} env The application's environment, such as `process.env`.
7
+ * @returns {import("./types.js").ResolveReference}
8
+ */
9
+ function createEnvironmentReferenceResolver(env) {
10
+ if (!env) {
11
+ throw new TypeError("createEnvironmentReferenceResolver requires the application's environment, such as process.env.");
12
+ }
4
13
  return async (reference) => {
5
14
  const match = /^env:([A-Z_][A-Z0-9_]*)$/u.exec(reference);
6
15
  if (!match || typeof env[match[1]] !== "string" || (!env[match[1]].trim() || env[match[1]].trim() === "MISSING")) {
@@ -2,6 +2,15 @@
2
2
  // so each sentence also has a messageCode (with messageParams) that renders it from
3
3
  // the "jskit.<messageCode>" catalog entry; message stays that English text.
4
4
  class ConnectorError extends Error {
5
+ /**
6
+ * @param {string} code
7
+ * @param {string} message
8
+ * @param {object} [details]
9
+ * @param {number} [details.statusCode]
10
+ * @param {number} [details.retryAfterSeconds]
11
+ * @param {string} [details.messageCode]
12
+ * @param {Record<string, unknown>} [details.messageParams]
13
+ */
5
14
  constructor(code, message, { statusCode = 400, retryAfterSeconds, messageCode, messageParams } = {}) {
6
15
  super(message);
7
16
  this.name = "ConnectorError";
@@ -15,20 +24,31 @@ class ConnectorError extends Error {
15
24
  }
16
25
  }
17
26
 
27
+ /**
28
+ * The refusal for a provider's failed HTTP response, by its status, without the
29
+ * provider's own text: what a provider's exchange() throws for a response it
30
+ * does not map itself.
31
+ * @param {number} status
32
+ */
33
+ function providerStatusError(status) {
34
+ if (status === 401) {
35
+ return new ConnectorError("connector_reconnect_required", "Connect this account again.", { statusCode: 401, messageCode: "connectors.connection.connectAccountAgain" });
36
+ }
37
+ if (status === 403) return new ConnectorError("connector_permission_denied", "The provider denied this operation.", { statusCode: 403, messageCode: "connectors.connection.providerDeniedOperation" });
38
+ if (status === 429) return new ConnectorError("connector_rate_limited", "The provider's request limit was reached.", { statusCode: 429, messageCode: "connectors.connection.providerRequestLimitReached" });
39
+ return new ConnectorError("connector_provider_failed", "The provider request failed. Try again.", { statusCode: 502, messageCode: "connectors.connection.providerRequestFailedTryAgain" });
40
+ }
41
+
18
42
  function providerError(error) {
19
43
  if (error instanceof ConnectorError) return error;
20
44
  const status = Number(error?.status || error?.statusCode || 0);
21
45
  if (error?.name === "AbortError") return new ConnectorError("connector_cancelled", "The operation was cancelled.", { messageCode: "connectors.connection.operationCancelled" });
22
46
  if (error?.name === "TimeoutError") return new ConnectorError("connector_provider_timeout", "The provider request timed out. Try again.", { statusCode: 504 });
23
- if (["invalid_grant", "invalid_client"].includes(error?.error) || status === 401) {
24
- return new ConnectorError("connector_reconnect_required", "Connect this account again.", { statusCode: 401, messageCode: "connectors.connection.connectAccountAgain" });
25
- }
47
+ if (["invalid_grant", "invalid_client"].includes(error?.error) || status === 401) return providerStatusError(401);
26
48
  if (["access_denied", "user_cancelled_login", "user_cancelled_authorize"].includes(error?.error)) {
27
49
  return new ConnectorError("connector_consent_denied", "Account access was declined.");
28
50
  }
29
- if (status === 403) return new ConnectorError("connector_permission_denied", "The provider denied this operation.", { statusCode: 403, messageCode: "connectors.connection.providerDeniedOperation" });
30
- if (status === 429) return new ConnectorError("connector_rate_limited", "The provider's request limit was reached.", { statusCode: 429, messageCode: "connectors.connection.providerRequestLimitReached" });
31
- return new ConnectorError("connector_provider_failed", "The provider request failed. Try again.", { statusCode: 502, messageCode: "connectors.connection.providerRequestFailedTryAgain" });
51
+ return providerStatusError(status);
32
52
  }
33
53
 
34
- export { ConnectorError, providerError };
54
+ export { ConnectorError, providerError, providerStatusError };
@@ -5,8 +5,18 @@ import path from "node:path";
5
5
  import lockfile from "proper-lockfile";
6
6
  import { ConnectorError } from "./errors.js";
7
7
 
8
+ /**
9
+ * Connections kept as encrypted JSON files in a private directory, one locked file per connection.
10
+ * @param {object} options
11
+ * @param {string} options.directory An absolute private runtime directory.
12
+ * @param {import("./types.js").CredentialProtection} options.protection
13
+ * @param {number} [options.directoryMode]
14
+ * @param {number} [options.fileMode]
15
+ * @param {() => number} [options.now]
16
+ */
8
17
  function createFileConnectionStore({ directory, protection, directoryMode = 0o700, fileMode = 0o600, now = Date.now }) {
9
18
  if (typeof directory !== "string" || !path.isAbsolute(directory)) throw new TypeError("Use an absolute private runtime directory.");
19
+ // readable-check: allow method-probe - input validation: the application passes its credential protection
10
20
  if (typeof protection?.seal !== "function" || typeof protection?.open !== "function") throw new TypeError("Credential protection is required.");
11
21
  const root = path.resolve(directory);
12
22
  const storageError = () => new ConnectorError("connector_storage_invalid", "Connection state could not be read safely.", { statusCode: 500 });
@@ -1,4 +1,4 @@
1
1
  export { createConnectionService } from "./connectionService.js";
2
2
  export { createEnvironmentReferenceResolver } from "./environmentReferences.js";
3
- export { ConnectorError } from "./errors.js";
3
+ export { ConnectorError, providerStatusError } from "./errors.js";
4
4
  export { createConnectorsFeature } from "./ConnectorsFeature.js";
@@ -2,10 +2,18 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  const hash = (value) => createHash("sha256").update(value).digest("hex");
4
4
 
5
+ /**
6
+ * Connections kept encrypted in the connector tables, each locked in a transaction while it is used.
7
+ * @param {object} options
8
+ * @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} options.knex The application's transactional Knex client.
9
+ * @param {import("./types.js").CredentialProtection} options.protection
10
+ */
5
11
  function createKnexConnectionStore({ knex, protection }) {
12
+ // readable-check: allow method-probe - input validation: the application passes its own Knex instance
6
13
  if (typeof knex !== "function" || typeof knex.transaction !== "function") {
7
- throw new TypeError("Connector storage requires the application's transactional Knex client.");
14
+ throw new TypeError("createKnexConnectionStore requires the application's Knex instance.");
8
15
  }
16
+ // readable-check: allow method-probe - input validation: the application passes its credential protection
9
17
  if (typeof protection?.seal !== "function" || typeof protection?.open !== "function") {
10
18
  throw new TypeError("Connector storage requires credential protection.");
11
19
  }
@@ -56,7 +64,10 @@ function createKnexConnectionStore({ knex, protection }) {
56
64
  return knex("connector_authorization_attempts").where("expires_at", "<=", before).delete();
57
65
  }
58
66
 
59
- return Object.freeze({ withConnection, pruneExpiredAttempts });
67
+ return Object.freeze({
68
+ withConnection,
69
+ pruneExpiredAttempts
70
+ });
60
71
  }
61
72
 
62
73
  export { createKnexConnectionStore };
@@ -0,0 +1,291 @@
1
+ import * as oauth from "oauth4webapi";
2
+ import { createHttpClient } from "@jskit-ai/http-runtime/client";
3
+ import { ConnectorError } from "./errors.js";
4
+
5
+ const DEFAULT_SCOPE_SEPARATOR = " ";
6
+ // Every OAuth token request takes this timeout; a provider's operations default to it.
7
+ const REQUEST_TIMEOUT_MS = 15_000;
8
+ const DEFAULT_TOKEN_REFRESH_LEEWAY_MS = 30_000;
9
+
10
+ /**
11
+ * A provider as an integration's requests use it: with its runtime for the
12
+ * integration's settings, its checkOperation for the credential mode and its
13
+ * defaults applied.
14
+ * @typedef {import("./types.js").ConnectorProvider & { checkOperation: string, scopeSeparator: string, requestTimeoutMs: number, tokenRefreshLeewayMs: number }} ConnectionProvider
15
+ */
16
+
17
+ /**
18
+ * @param {import("./types.js").ConnectorProvider} definition
19
+ * @param {{ settings: Record<string, any>, authentication: { method: string } }} integration
20
+ * @returns {ConnectionProvider}
21
+ */
22
+ function connectionProvider(definition, integration) {
23
+ // readable-check: allow method-probe - optional operation: runtimeForSettings is an optional hook of the provider definition
24
+ const runtime = typeof definition.runtimeForSettings === "function" ? { ...definition, ...definition.runtimeForSettings(integration.settings) } : definition;
25
+ return {
26
+ ...runtime,
27
+ // readable-check: allow method-probe - data: a provider definition gives checkOperation as an operation id or as a function of the credential mode
28
+ checkOperation: typeof runtime.checkOperation === "function" ? runtime.checkOperation(integration.authentication.method) : runtime.checkOperation,
29
+ scopeSeparator: runtime.scopeSeparator || DEFAULT_SCOPE_SEPARATOR,
30
+ requestTimeoutMs: runtime.requestTimeoutMs ?? REQUEST_TIMEOUT_MS,
31
+ tokenRefreshLeewayMs: runtime.tokenRefreshLeewayMs ?? DEFAULT_TOKEN_REFRESH_LEEWAY_MS
32
+ };
33
+ }
34
+
35
+ // An operation without scopes always runs; otherwise one of its scopes must be present.
36
+ function requireOperationScope(operation, scopes, message, { messageCode }) {
37
+ if (operation.scopes.length && !operation.scopes.some((scope) => scopes.includes(scope))) {
38
+ throw new ConnectorError("connector_scope_missing", message, { statusCode: 403, messageCode });
39
+ }
40
+ }
41
+
42
+ // The token response oauth4webapi processes, rewritten by the provider's normalizeTokenResponse() when it has one.
43
+ function tokenResponse(provider, response, details) {
44
+ // readable-check: allow method-probe - optional operation: normalizeTokenResponse is an optional hook of the provider definition
45
+ return typeof provider.normalizeTokenResponse === "function" ? provider.normalizeTokenResponse(response, details) : response;
46
+ }
47
+
48
+ // The headers that carry a connection's credential: the provider's API-key headers, or its OAuth headers and bearer token.
49
+ function credentialHeaders(provider, connection, settings) {
50
+ if (connection.method === "none") return {};
51
+ if (connection.method === "api-key") {
52
+ // readable-check: allow method-probe - optional operation: apiKey.headers is an optional hook of the provider definition
53
+ return typeof provider.apiKey.headers === "function" ? provider.apiKey.headers(connection.apiKey, settings) : {};
54
+ }
55
+ return {
56
+ // readable-check: allow method-probe - optional operation: oauthHeaders is an optional hook of the provider definition
57
+ ...(typeof provider.oauthHeaders === "function" ? provider.oauthHeaders({ clientId: connection.clientId }) : {}),
58
+ Authorization: `Bearer ${connection.tokens.accessToken}`
59
+ };
60
+ }
61
+
62
+ // A signal that aborts at the timeout or with the caller's signal.
63
+ function requestSignal(signal, timeoutMs) {
64
+ const timeout = AbortSignal.timeout(timeoutMs);
65
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
66
+ }
67
+
68
+ // Builds the provider request, reporting schema input errors as connector_input_invalid.
69
+ function operationRequest(operation, input, settings, providerData) {
70
+ try { return operation.request(input, settings, providerData); } catch (error) {
71
+ if (!error.fieldErrors) throw error;
72
+ const failure = new ConnectorError("connector_input_invalid", "Check the operation's input values.", { statusCode: 422, messageCode: "connectors.connection.checkOperationInputValues" });
73
+ failure.fieldErrors = error.fieldErrors;
74
+ if (error.fieldErrorCodes) failure.fieldErrorCodes = error.fieldErrorCodes;
75
+ throw failure;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The requests a connection sends its provider: operations, and the token
81
+ * grants that keep the connection's access current.
82
+ * @param {object} dependencies
83
+ * @param {typeof globalThis.fetch} dependencies.fetchImpl
84
+ * @param {() => number} dependencies.now
85
+ * @param {import("./types.js").ResolveReference} dependencies.resolveReference
86
+ */
87
+ function createProviderRequests({ fetchImpl, now, resolveReference }) {
88
+ const http = createHttpClient({ fetchImpl, csrf: { enabled: false }, credentials: "omit" });
89
+
90
+ /**
91
+ * The oauth4webapi options of an OAuth token request: the fetch that sends
92
+ * it in the provider's token request encoding, and its timeout joined with
93
+ * the caller's signal.
94
+ * @param {AbortSignal | undefined} signal
95
+ * @param {ConnectionProvider} provider
96
+ */
97
+ function tokenRequestOptions(signal, provider) {
98
+ return {
99
+ [oauth.customFetch]: provider.tokenRequestEncoding === "json" ? (address, options) => {
100
+ const headers = new Headers(options.headers);
101
+ headers.set("Content-Type", "application/json");
102
+ return fetchImpl(address, { ...options, headers,
103
+ body: JSON.stringify(Object.fromEntries(new URLSearchParams(options.body))) });
104
+ } : fetchImpl,
105
+ signal: requestSignal(signal, REQUEST_TIMEOUT_MS)
106
+ };
107
+ }
108
+
109
+ /**
110
+ * @param {ConnectionProvider} provider
111
+ * @param {string} operationId
112
+ * @param {any} input
113
+ * @param {Record<string, any>} connection
114
+ * @param {AbortSignal} [signal]
115
+ */
116
+ async function request(provider, operationId, input, connection, signal) {
117
+ const operation = provider.operations[operationId];
118
+ if (!operation || !Object.hasOwn(provider.operations, operationId)) {
119
+ throw new ConnectorError("connector_operation_unknown", "This provider operation is not available.", { messageCode: "connectors.connection.providerOperationNotAvailable" });
120
+ }
121
+ if (["oauth2", "service-account"].includes(connection.method || "oauth2")) {
122
+ requireOperationScope(operation, connection.grantedScopes, "Connect again with the permission required for this operation.", { messageCode: "connectors.connection.connectAgainPermissionRequiredOperation" });
123
+ }
124
+ const settings = connection.settings || {};
125
+ const descriptor = operationRequest(operation, input, settings, connection.providerData);
126
+ const url = new URL(descriptor.url);
127
+ // readable-check: allow method-probe - data: a provider definition gives apiOrigins as a list or as a function of the settings
128
+ const origins = typeof provider.apiOrigins === "function" ? provider.apiOrigins(settings, connection.providerData) : provider.apiOrigins;
129
+ if (url.protocol !== "https:" || url.username || url.password || !origins.includes(url.origin)) {
130
+ throw new ConnectorError("connector_destination_invalid", "The provider operation has an invalid destination.", { messageCode: "connectors.connection.providerOperationInvalidDestination" });
131
+ }
132
+ // readable-check: allow method-probe - optional operation: apiKey.pathPrefix is an optional hook of the provider definition
133
+ if (connection.method === "api-key" && typeof provider.apiKey.pathPrefix === "function") {
134
+ const prefix = provider.apiKey.pathPrefix(connection.apiKey);
135
+ if (typeof prefix !== "string" || !prefix.startsWith("/") || prefix.startsWith("//") ||
136
+ /[?#\\]/.test(prefix) || prefix.split("/").some((part) => /^(?:\.|%2e){1,2}$/i.test(part))) {
137
+ throw new ConnectorError("connector_request_invalid", "This provider has an invalid credential path.", { messageCode: "connectors.connection.providerInvalidCredentialPath" });
138
+ }
139
+ // A pathname assignment cannot replace the already checked origin.
140
+ url.pathname = prefix + url.pathname;
141
+ }
142
+ if (connection.method === "api-key" && provider.apiKey.queryParameter) {
143
+ url.searchParams.set(provider.apiKey.queryParameter, connection.apiKey);
144
+ }
145
+ let body = descriptor.body;
146
+ if (connection.method === "api-key" && provider.apiKey.bodyParameter) {
147
+ if (!body || typeof body !== "object" || Array.isArray(body) || ["GET", "HEAD"].includes(descriptor.method)) {
148
+ throw new ConnectorError("connector_request_invalid", "This provider requires a JSON request body.", { messageCode: "connectors.connection.providerRequiresJsonRequestBody" });
149
+ }
150
+ body = { ...body, [provider.apiKey.bodyParameter]: connection.apiKey };
151
+ }
152
+ const options = {
153
+ method: descriptor.method,
154
+ body,
155
+ headers: { ...descriptor.headers, Accept: "application/json", ...credentialHeaders(provider, connection, settings) },
156
+ redirect: "error",
157
+ signal: requestSignal(signal, provider.requestTimeoutMs)
158
+ };
159
+ let result;
160
+ try {
161
+ // readable-check: allow method-probe - optional operation: exchange is an optional hook of the provider definition
162
+ result = typeof provider.exchange === "function"
163
+ ? await provider.exchange(url.href, options, { fetchImpl, request: http.request, resolveReference,
164
+ settings, apiKey: connection.method === "api-key" ? connection.apiKey : undefined })
165
+ : await http.request(url.href, options);
166
+ } catch (error) {
167
+ // Transports can wrap cancellation in a network or protocol error.
168
+ options.signal.throwIfAborted();
169
+ throw error;
170
+ }
171
+ // The validator also receives the operation's own request, so it can check the provider answered what was asked.
172
+ // readable-check: allow method-probe - optional operation: validateResult is an optional hook of the provider definition
173
+ if (typeof operation.validateResult === "function" && !operation.validateResult(result, descriptor)) {
174
+ throw new ConnectorError("connector_response_invalid", "The provider returned an unexpected response.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedUnexpectedResponse" });
175
+ }
176
+ return result;
177
+ }
178
+
179
+ /**
180
+ * @param {any} response
181
+ * @param {Record<string, any> | null} previous
182
+ * @param {string[]} requestedScopes
183
+ * @param {ConnectionProvider} provider
184
+ */
185
+ function tokensFrom(response, previous, requestedScopes, provider) {
186
+ return {
187
+ // readable-check: allow method-probe - optional operation: dataFromTokenResponse is an optional hook of the provider definition
188
+ ...(typeof provider.dataFromTokenResponse === "function" ? { providerData: provider.dataFromTokenResponse(response, previous?.providerData) } : {}),
189
+ tokens: {
190
+ accessToken: response.access_token,
191
+ refreshToken: response.refresh_token || previous?.tokens.refreshToken || null,
192
+ expiresAt: response.expires_in === undefined ? null : now() + response.expires_in * 1000
193
+ },
194
+ grantedScopes: response.scope === undefined
195
+ ? [...(previous?.grantedScopes || requestedScopes)]
196
+ : response.scope.split(provider.scopeSeparator).map((scope) => scope.trim()).filter(Boolean)
197
+ };
198
+ }
199
+
200
+ /**
201
+ * @param {Record<string, any>} integration
202
+ * @param {ConnectionProvider} provider
203
+ * @param {{ credential: string, fingerprint: string }} binding
204
+ * @param {AbortSignal} [signal]
205
+ */
206
+ async function serviceAccountGrant(integration, provider, binding, signal) {
207
+ const grantSignal = requestSignal(signal, provider.requestTimeoutMs);
208
+ grantSignal.throwIfAborted();
209
+ let response;
210
+ try {
211
+ response = await provider.serviceAccountGrant({
212
+ credential: binding.credential, settings: structuredClone(integration.settings),
213
+ scopes: [...integration.scopes], fetchImpl, signal: grantSignal, now: now()
214
+ });
215
+ } catch (error) {
216
+ grantSignal.throwIfAborted();
217
+ throw error;
218
+ }
219
+ grantSignal.throwIfAborted();
220
+ if (typeof response?.access_token !== "string" || !response.access_token || /\s/u.test(response.access_token) ||
221
+ // readable-check: allow optional-call - data: the provider's token response
222
+ response.token_type?.toLowerCase?.() !== "bearer" ||
223
+ !Number.isFinite(response.expires_in) || response.expires_in <= 0 || response.expires_in > 86_400 ||
224
+ response.refresh_token !== undefined || (response.scope !== undefined && typeof response.scope !== "string")) {
225
+ throw new ConnectorError("connector_response_invalid", "The provider returned an invalid service-account token.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedInvalidServiceAccount" });
226
+ }
227
+ const grant = tokensFrom(response, null, integration.scopes, provider);
228
+ grant.grantedScopes = grant.grantedScopes.filter((scope) => integration.scopes.includes(scope));
229
+ return { ...grant, credentialFingerprint: binding.fingerprint };
230
+ }
231
+
232
+ /**
233
+ * @param {ConnectionProvider} provider
234
+ * @param {Record<string, any>} connection
235
+ * @param {any} verificationInput
236
+ * @param {string[]} requestedScopes
237
+ * @param {AbortSignal} [signal]
238
+ */
239
+ async function verifyOAuthConnection(provider, connection, verificationInput, requestedScopes, signal) {
240
+ const verification = await request(provider, provider.checkOperation, verificationInput, connection, signal);
241
+ // readable-check: allow method-probe - optional operation: accountLabelFromVerification is an optional hook of the provider definition
242
+ if (typeof provider.accountLabelFromVerification === "function") {
243
+ const label = provider.accountLabelFromVerification(verification);
244
+ if (typeof label !== "string" || !label.trim() || label.length > 256 || /[\p{Cc}\p{Cf}]/u.test(label)) {
245
+ throw new ConnectorError("connector_response_invalid", "The provider returned an invalid account label.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedInvalidAccountLabel" });
246
+ }
247
+ connection.accountLabel = label;
248
+ }
249
+ // readable-check: allow method-probe - optional operation: grantedScopesFromVerification is an optional hook of the provider definition
250
+ if (typeof provider.grantedScopesFromVerification !== "function") return;
251
+ const scopes = provider.grantedScopesFromVerification(verification, { clientId: connection.clientId });
252
+ if (!Array.isArray(scopes) || scopes.some((scope) => typeof scope !== "string" || !scope)) {
253
+ throw new ConnectorError("connector_response_invalid", "The provider returned an invalid permission grant.", { statusCode: 502, messageCode: "connectors.connection.providerReturnedInvalidPermissionGrant" });
254
+ }
255
+ connection.grantedScopes = connection.grantedScopes.filter((scope) => requestedScopes.includes(scope) && scopes.includes(scope));
256
+ requireOperationScope(provider.operations[provider.checkOperation], connection.grantedScopes, "Connect again with the permission required for verification.", { messageCode: "connectors.connection.connectAgainPermissionRequiredVerification" });
257
+ }
258
+
259
+ /**
260
+ * @param {Record<string, any>} registration
261
+ * @param {ConnectionProvider} provider
262
+ * @param {Record<string, any>} settings
263
+ * @param {string[]} scopes
264
+ * @param {AbortSignal} [signal]
265
+ */
266
+ async function clientCredentialsGrant(registration, provider, settings, scopes, signal) {
267
+ const response = await oauth.clientCredentialsGrantRequest(
268
+ registration.oauth, registration.client, registration.clientAuth,
269
+ { scope: scopes.join(provider.scopeSeparator), ...(registration.resource ? { resource: registration.resource } : {}) },
270
+ tokenRequestOptions(signal, provider)
271
+ );
272
+ const result = await oauth.processClientCredentialsResponse(registration.oauth, registration.client,
273
+ await tokenResponse(provider, response, { settings, grantType: "client_credentials" }));
274
+ const grant = tokensFrom(result, null, scopes, provider);
275
+ // Client credentials renew with the application's secret, never a user refresh token.
276
+ grant.tokens.refreshToken = null;
277
+ grant.grantedScopes = grant.grantedScopes.filter((scope) => scopes.includes(scope));
278
+ return grant;
279
+ }
280
+
281
+ return Object.freeze({
282
+ tokenRequestOptions,
283
+ request,
284
+ tokensFrom,
285
+ serviceAccountGrant,
286
+ verifyOAuthConnection,
287
+ clientCredentialsGrant
288
+ });
289
+ }
290
+
291
+ export { connectionProvider, createProviderRequests, operationRequest, requireOperationScope, tokenResponse };
@@ -0,0 +1,150 @@
1
+ // The type of the `connectors.core` capability, for features that require it,
2
+ // and the contracts of what an application passes createConnectionService().
3
+
4
+ /** @typedef {ReturnType<typeof import("./connectionService.js").createConnectionService>} ConnectorsCore */
5
+ /** @typedef {Parameters<typeof import("./connectionService.js").createConnectionService>[0]} ConnectionServiceOptions */
6
+ /** @typedef {ReturnType<typeof import("./credentialProtection.js").createCredentialProtection>} CredentialProtection */
7
+
8
+ /**
9
+ * The application identity a connection belongs to: one application and one
10
+ * user or shared subject in it.
11
+ * @typedef {object} ConnectionOwner
12
+ * @property {string} applicationId
13
+ * @property {string} subjectId
14
+ */
15
+
16
+ /**
17
+ * What a connector asks the application's access policy to decide.
18
+ * @typedef {object} ConnectorAuthorizationRequest
19
+ * @property {string} integrationId
20
+ * @property {string} operation The provider operation, or a connection step such as "connect", "status" or "disconnect".
21
+ * @property {string} accountMode The integration's account mode: "shared", "per-user" or "assistant".
22
+ * @property {{ action: string, decision: string }} [assistantPermission]
23
+ * In assistant execution, the assistant action and its configured decision ("ask" or "always").
24
+ * @property {unknown} [input] A copy of the operation's input, for an operation that has one.
25
+ */
26
+
27
+ /**
28
+ * The application's access policy: the owner whose connection the request may
29
+ * use, or null to deny it. An assistant action whose decision is "ask" also
30
+ * needs `approved: true`.
31
+ * @typedef {(context: any, request: ConnectorAuthorizationRequest) => Promise<(ConnectionOwner & { approved?: boolean }) | null>} ConnectorAuthorize
32
+ */
33
+
34
+ /**
35
+ * Resolves a binding reference such as `env:NAME` to its value. Resolvers of
36
+ * per-user accounts also receive the authorized owner and the integration slot.
37
+ * @typedef {(reference: string, owner?: ConnectionOwner, slot?: { integrationId: string, accountMode: string, providerId: string }) => Promise<string>} ResolveReference
38
+ */
39
+
40
+ /**
41
+ * A caller's request about one integration: the application context that
42
+ * authorize() receives and the integration's id.
43
+ * @typedef {object} IntegrationRequest
44
+ * @property {any} context
45
+ * @property {string} integrationId
46
+ */
47
+
48
+ /**
49
+ * A request that may reach the provider, which `signal` cancels.
50
+ * @typedef {IntegrationRequest & { signal?: AbortSignal }} ProviderRequest
51
+ */
52
+
53
+ /**
54
+ * A request that connects an account, with the input its verification takes.
55
+ * @typedef {ProviderRequest & { verificationInput?: Record<string, any> }} ConnectRequest
56
+ */
57
+
58
+ /**
59
+ * One connection's stored state, held under the store's lock while work runs.
60
+ * @typedef {object} ConnectionRecord
61
+ * @property {Record<string, any> | null} connection The stored connection, or null.
62
+ * @property {(connection: object) => Promise<void>} save
63
+ * @property {() => Promise<void>} remove Removes the connection and its authorization attempts.
64
+ * @property {(attempt: Record<string, any> & { state: string, expiresAt: number }) => Promise<void>} putAttempt Stores an authorization attempt under its state.
65
+ * @property {(query: { after: number }) => Promise<Record<string, any> | null>} latestAttempt The newest attempt that expires after `after`.
66
+ * @property {(state: string) => Promise<Record<string, any> | null>} consumeAttempt Removes and answers the attempt with this state.
67
+ */
68
+
69
+ /**
70
+ * Where connections are kept: createKnexConnectionStore(),
71
+ * createFileConnectionStore() or the application's own store.
72
+ * @typedef {object} ConnectionStore
73
+ * @property {<T>(slot: { owner: ConnectionOwner, integrationId: string }, work: (record: ConnectionRecord) => Promise<T>) => Promise<T>} withConnection
74
+ */
75
+
76
+ /**
77
+ * The request a provider operation sends.
78
+ * @typedef {object} ConnectorRequest
79
+ * @property {string} method
80
+ * @property {string} url
81
+ * @property {Record<string, string>} [headers]
82
+ * @property {any} [body]
83
+ */
84
+
85
+ /**
86
+ * One operation a provider offers.
87
+ * @typedef {object} ConnectorOperation
88
+ * @property {string[]} scopes The permissions of which one must be granted; an empty list always runs.
89
+ * @property {(input: any, settings: Record<string, any>, providerData?: any) => ConnectorRequest} request
90
+ * Builds the request for an input; throws an error with `fieldErrors` for an invalid one.
91
+ * @property {(result: any, request: ConnectorRequest) => boolean} [validateResult] Answers whether the response is what was asked.
92
+ * @property {string} [assistantAction] The assistant action whose permission covers it; its operation id otherwise.
93
+ * @property {string[]} [authenticationMethods] The credential modes it runs with; every mode otherwise.
94
+ */
95
+
96
+ /**
97
+ * What a provider's exchange() receives besides the URL and the request options.
98
+ * @typedef {object} ConnectorExchangeTransport
99
+ * @property {typeof globalThis.fetch} fetchImpl
100
+ * @property {ReturnType<typeof import("@jskit-ai/http-runtime/client").createHttpClient>["request"]} request The parsed-JSON HTTP client.
101
+ * @property {ResolveReference} resolveReference
102
+ * @property {Record<string, any>} settings
103
+ * @property {string | undefined} apiKey The API key of an API-key connection; undefined otherwise.
104
+ */
105
+
106
+ /**
107
+ * A connector provider: its shared definition (id, scopes, credential modes,
108
+ * account modes and settings, which validateIntegrationConfiguration() reads)
109
+ * and the server runtime createConnectionService() calls. A field given as a
110
+ * value or as a function of the settings may take either form.
111
+ * @typedef {object} ConnectorProvider
112
+ * @property {string} id
113
+ * @property {Record<string, ConnectorOperation>} operations
114
+ * @property {string | ((method: string) => string)} checkOperation The operation that verifies a connection.
115
+ * @property {string[] | ((settings: Record<string, any>, providerData?: any) => string[])} apiOrigins The HTTPS origins its requests may reach.
116
+ * @property {(settings: Record<string, any>) => string[]} [authenticationMethodsForSettings] The credential modes for these settings, in place of the definition's list.
117
+ * @property {(settings: Record<string, any>) => string[]} [accountModesForSettings] The account modes for these settings, in place of the definition's list.
118
+ * @property {(grantType: string, settings: Record<string, any>) => Array<{ value: string }>} [scopesForGrantType] The scopes an OAuth grant type offers.
119
+ * @property {(settings: Record<string, any>) => Array<{ value: string }>} [scopesForSettings] The scopes for these settings, in place of the definition's list.
120
+ * @property {(clientId: string) => unknown} [validateClientId] True for a client ID the provider accepts; otherwise its field error.
121
+ * @property {(settings: Record<string, any>) => object} [runtimeForSettings] Runtime fields that replace these for the integration's settings.
122
+ * @property {Array<{ value: string }>} [assistantActions]
123
+ * @property {number} [requestTimeoutMs] The timeout of its operations and service-account grants. Defaults to 15 seconds, the timeout of every OAuth token request.
124
+ * @property {string} [scopeSeparator] Defaults to a space.
125
+ * @property {object | ((settings: Record<string, any>) => object)} [oauth] The OAuth authorization server metadata.
126
+ * @property {string | ((settings: Record<string, any>) => string)} [oauthResource]
127
+ * @property {boolean} [oauthPkce] False for a provider without PKCE.
128
+ * @property {string} [oauthBasicEncoding] "raw" sends Basic client credentials without form encoding.
129
+ * @property {string} [oauthClientIdParameter] The parameter that carries the client ID, when it is not `client_id`.
130
+ * @property {(client: { clientId: string }) => Record<string, string>} [oauthHeaders]
131
+ * @property {Record<string, string>} [authorizationParameters]
132
+ * @property {(settings: Record<string, any>) => string | undefined} [authorizationScopeParameter]
133
+ * @property {boolean} [scopesInAuthorizationResponse]
134
+ * @property {string} [tokenRequestEncoding] "json" sends every token request as JSON instead of a form.
135
+ * @property {number} [tokenRefreshLeewayMs] Defaults to 30 seconds.
136
+ * @property {boolean} [refreshRequiresRedirectUri]
137
+ * @property {(callbackUrl: string) => boolean} [validateCallbackUrl]
138
+ * @property {(response: Response, details: { settings: Record<string, any>, grantType: string }) => Response | Promise<Response>} [normalizeTokenResponse]
139
+ * @property {(response: any, previous: any) => any} [dataFromTokenResponse]
140
+ * @property {(verification: any) => string} [accountLabelFromVerification]
141
+ * @property {(verification: any, client: { clientId: string }) => string[]} [grantedScopesFromVerification]
142
+ * @property {(request: { credential: string, settings: Record<string, any>, scopes: string[], fetchImpl: typeof globalThis.fetch, signal: AbortSignal, now: number }) => Promise<any>} [serviceAccountGrant]
143
+ * @property {{ headers?: (apiKey: string, settings: Record<string, any>) => Record<string, string>, queryParameter?: string, bodyParameter?: string, pathPrefix?: (apiKey: string) => string }} [apiKey]
144
+ * Where an API-key connection places its key.
145
+ * @property {boolean} [apiKeySecretOptional]
146
+ * @property {(url: string, options: object, transport: ConnectorExchangeTransport) => Promise<any>} [exchange]
147
+ * Sends the request itself, for a protocol such as MCP or AWS Signature Version 4.
148
+ */
149
+
150
+ export {};