@jskit-ai/connectors-core 0.1.62 → 0.1.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -292,6 +292,10 @@ Provider `settingsFields[].authenticationMethods` restricts fields to particular
292
292
  credential modes; the shared parser rejects values left in an incompatible mode.
293
293
 
294
294
  Provider operations receive `(input, settings)` when constructing a request.
295
+ An operation's `validateResult(result, request)` receives the parsed result and
296
+ the request object its own `request` returned (`method`, `url`, `body`,
297
+ `headers`), so it can confirm the provider returned the record, page or range
298
+ that request asked for; a false return reports `connector_response_invalid`.
295
299
  OAuth providers may supply `oauth(settings)` to select regional authorization
296
300
  metadata. `oauthResource` may be a string or function of those settings; when
297
301
  present, it is sent as `resource` in consent, code exchange and refresh. The
@@ -392,8 +396,7 @@ the core and provider contracts are verified with controlled service-account fix
392
396
  ## File storage
393
397
 
394
398
  Import `createFileConnectionStore` and `createCredentialProtection` from
395
- `@jskit-ai/connectors-core/server/file-storage`. This entry point does not import
396
- or require database-runtime. Use `createFileConnectionStore({ directory,
399
+ `@jskit-ai/connectors-core/server/file-storage`. Use `createFileConnectionStore({ directory,
397
400
  protection })` with an absolute private directory outside application source.
398
401
  Each connection uses an encrypted JSON record containing its grant and pending
399
402
  authorization attempts. The same protection contract is described below.
@@ -434,9 +437,8 @@ an old key before those rows are rewritten makes them unreadable. Applications
434
437
  with an existing vault can instead provide compatible `seal(value, binding)`
435
438
  and `open(ciphertext, binding)` methods.
436
439
 
437
- Install `@jskit-ai/database-runtime` explicitly when using the Knex store. It is
438
- an optional peer: editing portable configuration does not install or activate a
439
- database provider. Applications using another store do not need this peer.
440
+ The Knex store uses the application's own Knex client and its `transaction()`;
441
+ editing portable configuration does not install or activate a database provider.
440
442
 
441
443
  The package declares its authoritative migration directory in `package.json`.
442
444
  Use the normal app-owned `knexfile.js` and migration scripts described by the
@@ -551,3 +553,12 @@ authenticationMethod)` filters these choices; configuration validation uses the
551
553
  selected method. Forms remove incompatible scopes on method changes and select
552
554
  recommended method-specific scopes when entering that mode. Granola uses this
553
555
  to keep OAuth identity scopes out of API-key configuration.
556
+
557
+ Provider `setup` holds the guide `url` and ordered `steps` shown to whoever
558
+ configures the connection. `urlByAuthentication` and `stepsByAuthentication`
559
+ override them for one authentication method; `urlForSettings(settings, method)`
560
+ and `stepsForSettings(settings, method)` override both and return `undefined`
561
+ to fall through. `getProviderSetup(provider, settings, method)` resolves that
562
+ precedence into `{ url, steps }`. A provider with `configurationOnly`, or whose
563
+ `configurationOnlyForSettings(settings)` returns true, has no account
564
+ connection; `isProviderConfigurationOnly(provider, settings)` resolves both.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/connectors-core",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "description": "Portable integration configuration and server-side account connection runtime.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -53,13 +53,7 @@
53
53
  }
54
54
  },
55
55
  "peerDependencies": {
56
- "@jskit-ai/database-runtime": "0.1.245",
57
- "@jskit-ai/http-runtime": "0.1.243",
58
- "@jskit-ai/kernel": "0.1.245"
59
- },
60
- "peerDependenciesMeta": {
61
- "@jskit-ai/database-runtime": {
62
- "optional": true
63
- }
56
+ "@jskit-ai/http-runtime": "0.1.244",
57
+ "@jskit-ai/kernel": "0.1.246"
64
58
  }
65
59
  }
@@ -2,7 +2,7 @@ import * as oauth from "oauth4webapi";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import { createHash } from "node:crypto";
4
4
  import { createHttpClient } from "@jskit-ai/http-runtime/client";
5
- import { validateIntegrationConfiguration } from "../shared/configuration.js";
5
+ import { registrationClientAuthentication, registrationGrantType, validateIntegrationConfiguration } from "../shared/configuration.js";
6
6
  import { ConnectorError, providerError } from "./errors.js";
7
7
 
8
8
  function publicConnection(connection) {
@@ -19,6 +19,24 @@ function publicConnection(connection) {
19
19
  }
20
20
 
21
21
  const ENV_SEEDED_MESSAGE = "This connection comes from an Env value; unlink or replace that value instead.";
22
+ const VERIFICATION_SCOPE_MESSAGE = "Connection verification requires a permission absent from the application's configuration.";
23
+
24
+ // An operation without scopes always runs; otherwise one of its scopes must be present.
25
+ function requireOperationScope(operation, scopes, message) {
26
+ if (operation.scopes.length && !operation.scopes.some((scope) => scopes.includes(scope))) {
27
+ throw new ConnectorError("connector_scope_missing", message, { statusCode: 403 });
28
+ }
29
+ }
30
+
31
+ // Builds the provider request, reporting schema input errors as connector_input_invalid.
32
+ function operationRequest(operation, input, settings, providerData) {
33
+ try { return operation.request(input, settings || {}, providerData); } catch (error) {
34
+ if (!error.fieldErrors) throw error;
35
+ const failure = new ConnectorError("connector_input_invalid", "Check the operation's input values.", { statusCode: 422 });
36
+ failure.fieldErrors = error.fieldErrors;
37
+ throw failure;
38
+ }
39
+ }
22
40
 
23
41
  function validateCallbackUrl(value) {
24
42
  let url;
@@ -30,6 +48,29 @@ function validateCallbackUrl(value) {
30
48
  return url.href;
31
49
  }
32
50
 
51
+ // Whether a stored connection still belongs to the configured integration and
52
+ // OAuth client. `registration` is { registrationRef, clientId, grantType,
53
+ // tokenEndpointAuthMethod } with defaults applied; only OAuth reads it.
54
+ function connectionMatches(connection, integration, providerId, registration) {
55
+ const method = integration.authentication.method;
56
+ return connection.provider === providerId &&
57
+ (connection.method || "oauth2") === method &&
58
+ isDeepStrictEqual(connection.settings || {}, integration.settings || {}) &&
59
+ (!["api-key", "service-account"].includes(method) || connection.secretRef === integration.authentication.secretRef) &&
60
+ (method !== "service-account" || isDeepStrictEqual(connection.requestedScopes, integration.scopes)) &&
61
+ (method !== "oauth2" || (connection.registrationRef === registration.registrationRef &&
62
+ connection.clientId === registration.clientId &&
63
+ registrationGrantType(connection) === registration.grantType &&
64
+ (registration.grantType !== "client_credentials" || isDeepStrictEqual(connection.requestedScopes, integration.scopes)) &&
65
+ registrationClientAuthentication(connection) === registration.tokenEndpointAuthMethod));
66
+ }
67
+
68
+ // Providers that resend redirect_uri on refresh need the callback the grant was issued for.
69
+ function callbackMatches(connection, provider, registration) {
70
+ return registration.grantType !== "authorization_code" || !provider.refreshRequiresRedirectUri ||
71
+ connection.callbackUrl === registration.callbackUrl;
72
+ }
73
+
33
74
  function createConnectionService({
34
75
  configuration,
35
76
  providers,
@@ -105,8 +146,8 @@ function createConnectionService({
105
146
  if (registration.clientId.trim() === "MISSING") {
106
147
  throw new ConnectorError("connector_binding_missing", "The provider client ID is not configured.");
107
148
  }
108
- const grantType = registration.grantType || "authorization_code";
109
- const tokenEndpointAuthMethod = registration.tokenEndpointAuthMethod || "client_secret_post";
149
+ const grantType = registrationGrantType(registration);
150
+ const tokenEndpointAuthMethod = registrationClientAuthentication(registration);
110
151
  if (provider.oauthPkce === false && tokenEndpointAuthMethod === "none") {
111
152
  throw new ConnectorError("connector_mode_unavailable", "This provider's non-PKCE flow requires a confidential client.");
112
153
  }
@@ -235,11 +276,8 @@ function createConnectionService({
235
276
  if (integration.authentication.method !== "service-account" || typeof provider.serviceAccountGrant !== "function") {
236
277
  throw new ConnectorError("connector_mode_unavailable", "Select service-account credentials to verify this connection.");
237
278
  }
238
- provider.operations[provider.checkOperation].request(verificationInput, integration.settings || {});
239
- const required = provider.operations[provider.checkOperation].scopes;
240
- if (required.length && !required.some((scope) => integration.scopes.includes(scope))) {
241
- throw new ConnectorError("connector_scope_missing", "Connection verification requires a permission absent from the application's configuration.", { statusCode: 403 });
242
- }
279
+ operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
280
+ requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE);
243
281
  return store.withConnection({ owner, integrationId }, async ({ save }) => {
244
282
  try {
245
283
  const binding = await serviceAccountCredentialFor(integration);
@@ -273,17 +311,11 @@ function createConnectionService({
273
311
  if (!operation || !Object.hasOwn(provider.operations, operationId)) {
274
312
  throw new ConnectorError("connector_operation_unknown", "This provider operation is not available.");
275
313
  }
276
- if (["oauth2", "service-account"].includes(connection.method || "oauth2") && operation.scopes.length && !operation.scopes.some((scope) => connection.grantedScopes.includes(scope))) {
277
- throw new ConnectorError("connector_scope_missing", "Connect again with the permission required for this operation.", { statusCode: 403 });
314
+ if (["oauth2", "service-account"].includes(connection.method || "oauth2")) {
315
+ requireOperationScope(operation, connection.grantedScopes, "Connect again with the permission required for this operation.");
278
316
  }
279
- let request;
280
- try { request = operation.request(input, connection.settings || {}, connection.providerData); } catch (error) {
281
- if (!error.fieldErrors) throw error;
282
- const failure = new ConnectorError("connector_input_invalid", "Check the operation's input values.", { statusCode: 422 });
283
- failure.fieldErrors = error.fieldErrors;
284
- throw failure;
285
- }
286
- const url = new URL(request.url);
317
+ const descriptor = operationRequest(operation, input, connection.settings, connection.providerData);
318
+ const url = new URL(descriptor.url);
287
319
  const origins = typeof provider.apiOrigins === "function" ? provider.apiOrigins(connection.settings || {}, connection.providerData) : provider.apiOrigins;
288
320
  if (url.protocol !== "https:" || url.username || url.password || !origins.includes(url.origin)) {
289
321
  throw new ConnectorError("connector_destination_invalid", "The provider operation has an invalid destination.");
@@ -300,17 +332,17 @@ function createConnectionService({
300
332
  if (connection.method === "api-key" && provider.apiKey.queryParameter) {
301
333
  url.searchParams.set(provider.apiKey.queryParameter, connection.apiKey);
302
334
  }
303
- let body = request.body;
335
+ let body = descriptor.body;
304
336
  if (connection.method === "api-key" && provider.apiKey.bodyParameter) {
305
- if (!body || typeof body !== "object" || Array.isArray(body) || ["GET", "HEAD"].includes(request.method)) {
337
+ if (!body || typeof body !== "object" || Array.isArray(body) || ["GET", "HEAD"].includes(descriptor.method)) {
306
338
  throw new ConnectorError("connector_request_invalid", "This provider requires a JSON request body.");
307
339
  }
308
340
  body = { ...body, [provider.apiKey.bodyParameter]: connection.apiKey };
309
341
  }
310
342
  const options = {
311
- method: request.method,
343
+ method: descriptor.method,
312
344
  body,
313
- headers: { ...request.headers, Accept: "application/json", ...(connection.method === "api-key"
345
+ headers: { ...descriptor.headers, Accept: "application/json", ...(connection.method === "api-key"
314
346
  ? provider.apiKey.headers?.(connection.apiKey, connection.settings || {})
315
347
  : connection.method === "none" ? {} : {
316
348
  ...provider.oauthHeaders?.({ clientId: connection.clientId }), Authorization: `Bearer ${connection.tokens.accessToken}`
@@ -329,7 +361,8 @@ function createConnectionService({
329
361
  options.signal.throwIfAborted();
330
362
  throw error;
331
363
  }
332
- if (typeof operation.validateResult === "function" && !operation.validateResult(result)) {
364
+ // The validator also receives the operation's own request, so it can check the provider answered what was asked.
365
+ if (typeof operation.validateResult === "function" && !operation.validateResult(result, descriptor)) {
333
366
  throw new ConnectorError("connector_response_invalid", "The provider returned an unexpected response.", { statusCode: 502 });
334
367
  }
335
368
  return result;
@@ -364,10 +397,7 @@ function createConnectionService({
364
397
  throw new ConnectorError("connector_response_invalid", "The provider returned an invalid permission grant.", { statusCode: 502 });
365
398
  }
366
399
  connection.grantedScopes = connection.grantedScopes.filter((scope) => requestedScopes.includes(scope) && scopes.includes(scope));
367
- const required = provider.operations[provider.checkOperation].scopes;
368
- if (required.length && !required.some((scope) => connection.grantedScopes.includes(scope))) {
369
- throw new ConnectorError("connector_scope_missing", "Connect again with the permission required for verification.", { statusCode: 403 });
370
- }
400
+ requireOperationScope(provider.operations[provider.checkOperation], connection.grantedScopes, "Connect again with the permission required for verification.");
371
401
  }
372
402
 
373
403
  async function clientCredentialsGrant(registration, provider, settings, scopes, signal) {
@@ -392,11 +422,8 @@ function createConnectionService({
392
422
  if (registration.grantType !== "client_credentials") {
393
423
  throw new ConnectorError("connector_mode_unavailable", "Select client credentials to connect a service account.");
394
424
  }
395
- provider.operations[provider.checkOperation].request(verificationInput, integration.settings || {});
396
- const required = provider.operations[provider.checkOperation].scopes;
397
- if (required.length && !required.some((scope) => integration.scopes.includes(scope))) {
398
- throw new ConnectorError("connector_scope_missing", "Connection verification requires a permission absent from the application's configuration.", { statusCode: 403 });
399
- }
425
+ operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
426
+ requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE);
400
427
  return store.withConnection({ owner, integrationId }, async ({ save }) => {
401
428
  try {
402
429
  const connection = {
@@ -419,7 +446,7 @@ function createConnectionService({
419
446
  throw new ConnectorError("connector_env_managed", ENV_SEEDED_MESSAGE, { statusCode: 409 });
420
447
  }
421
448
  // Resource-specific providers validate the first document ID before opening consent.
422
- provider.operations[provider.checkOperation].request(verificationInput, integration.settings || {});
449
+ operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
423
450
  const registration = await registrationFor(integration, provider);
424
451
  if (registration.grantType !== "authorization_code") {
425
452
  throw new ConnectorError("connector_mode_unavailable", "Service accounts connect without browser consent.");
@@ -494,8 +521,8 @@ function createConnectionService({
494
521
  if (!attempt || attempt.expiresAt <= now() || attempt.provider !== provider.id ||
495
522
  (attempt.codeVerifier === null) !== (provider.oauthPkce === false) ||
496
523
  attempt.clientId !== registration.client.client_id || attempt.registrationRef !== registration.registrationRef ||
497
- (attempt.grantType || "authorization_code") !== registration.grantType ||
498
- (attempt.tokenEndpointAuthMethod || "client_secret_post") !== registration.tokenEndpointAuthMethod ||
524
+ registrationGrantType(attempt) !== registration.grantType ||
525
+ registrationClientAuthentication(attempt) !== registration.tokenEndpointAuthMethod ||
499
526
  attempt.callbackUrl !== registration.callbackUrl || JSON.stringify(attempt.scopes) !== JSON.stringify(integration.scopes) ||
500
527
  !isDeepStrictEqual(attempt.settings || {}, integration.settings || {})) {
501
528
  throw new ConnectorError("connector_attempt_invalid", "The authorization attempt is invalid or has expired.");
@@ -545,10 +572,7 @@ function createConnectionService({
545
572
  !provider.apiKey?.bodyParameter && typeof provider.apiKey?.pathPrefix !== "function"))) {
546
573
  throw new ConnectorError("connector_mode_unavailable", "Select the matching credential mode to verify this connection.");
547
574
  }
548
- const requiredScopes = provider.operations[provider.checkOperation].scopes;
549
- if (requiredScopes.length && !requiredScopes.some((scope) => integration.scopes.includes(scope))) {
550
- throw new ConnectorError("connector_scope_missing", "Connection verification requires a permission absent from the application's configuration.", { statusCode: 403 });
551
- }
575
+ requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE);
552
576
  return store.withConnection({ owner, integrationId }, async ({ save }) => {
553
577
  const connection = {
554
578
  integrationId, provider: provider.id, method,
@@ -575,9 +599,7 @@ function createConnectionService({
575
599
  if (!Object.hasOwn(provider.operations, operation)) {
576
600
  throw new ConnectorError("connector_operation_unknown", "This provider operation is not available.");
577
601
  }
578
- if (provider.operations[operation].scopes.length && !provider.operations[operation].scopes.some((scope) => integration.scopes.includes(scope))) {
579
- throw new ConnectorError("connector_scope_missing", "This operation requires a permission absent from the application's configuration.", { statusCode: 403 });
580
- }
602
+ requireOperationScope(provider.operations[operation], integration.scopes, "This operation requires a permission absent from the application's configuration.");
581
603
  const usesApiKey = integration.authentication.method === "api-key";
582
604
  const usesServiceAccount = integration.authentication.method === "service-account";
583
605
  const usesOAuth = integration.authentication.method === "oauth2";
@@ -588,16 +610,9 @@ function createConnectionService({
588
610
  connection = envSeededConnection(integrationId, integration, provider, registration, seed);
589
611
  await save(connection);
590
612
  }
591
- if (!connection || connection.status !== "connected" || connection.provider !== provider.id ||
592
- (connection.method || "oauth2") !== integration.authentication.method ||
593
- !isDeepStrictEqual(connection.settings || {}, integration.settings || {}) ||
594
- ((usesApiKey || usesServiceAccount) && connection.secretRef !== integration.authentication.secretRef) ||
595
- (usesServiceAccount && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
596
- (usesOAuth && (connection.registrationRef !== registration.registrationRef || connection.clientId !== registration.client.client_id ||
597
- (connection.grantType || "authorization_code") !== registration.grantType ||
598
- (registration.grantType === "client_credentials" && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
599
- (registration.grantType === "authorization_code" && provider.refreshRequiresRedirectUri && connection.callbackUrl !== registration.callbackUrl) ||
600
- (connection.tokenEndpointAuthMethod || "client_secret_post") !== registration.tokenEndpointAuthMethod))) {
613
+ if (!connection || connection.status !== "connected" ||
614
+ !connectionMatches(connection, integration, provider.id, usesOAuth ? { ...registration, clientId: registration.client.client_id } : null) ||
615
+ (usesOAuth && !callbackMatches(connection, provider, registration))) {
601
616
  throw new ConnectorError("connector_reconnect_required", "Connect this account again.", { statusCode: 401 });
602
617
  }
603
618
  try {
@@ -655,17 +670,14 @@ function createConnectionService({
655
670
 
656
671
  async function status({ context, integrationId }) {
657
672
  const { owner, integration, provider } = await access(context, integrationId, "status");
673
+ // Compare against the configured registration without resolving its secrets.
658
674
  const registration = config.registrations[integration.authentication.registrationRef];
675
+ const configured = {
676
+ registrationRef: integration.authentication.registrationRef, clientId: registration?.clientId,
677
+ grantType: registrationGrantType(registration), tokenEndpointAuthMethod: registrationClientAuthentication(registration)
678
+ };
659
679
  return store.withConnection({ owner, integrationId }, async ({ connection }) => {
660
- if (connection && !integration.authentication.refreshTokenRef && (connection.provider !== integration.provider ||
661
- (connection.method || "oauth2") !== integration.authentication.method ||
662
- !isDeepStrictEqual(connection.settings || {}, integration.settings || {}) ||
663
- (["api-key", "service-account"].includes(integration.authentication.method) && connection.secretRef !== integration.authentication.secretRef) ||
664
- (integration.authentication.method === "service-account" && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
665
- (integration.authentication.method === "oauth2" && (connection.registrationRef !== integration.authentication.registrationRef || connection.clientId !== registration?.clientId ||
666
- (connection.grantType || "authorization_code") !== (registration?.grantType || "authorization_code") ||
667
- (registration?.grantType === "client_credentials" && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
668
- (connection.tokenEndpointAuthMethod || "client_secret_post") !== (registration?.tokenEndpointAuthMethod || "client_secret_post"))))) {
680
+ if (connection && !integration.authentication.refreshTokenRef && !connectionMatches(connection, integration, integration.provider, configured)) {
669
681
  return publicConnection({ ...connection, status: "reconnect-required" });
670
682
  }
671
683
  let callbackUrl;
@@ -680,8 +692,7 @@ function createConnectionService({
680
692
  return { status: "connected", source: "env", provider: provider.id, integrationId, grantedScopes: [...integration.scopes], callbackUrl };
681
693
  }
682
694
  }
683
- if (connection && provider.refreshRequiresRedirectUri && resolved.grantType === "authorization_code" &&
684
- connection.callbackUrl !== resolved.callbackUrl) {
695
+ if (connection && !callbackMatches(connection, provider, resolved)) {
685
696
  return publicConnection({ ...connection, status: "reconnect-required" });
686
697
  }
687
698
  } else if (integration.authentication.method === "api-key") {
@@ -1,5 +1,4 @@
1
1
  import { createHash } from "node:crypto";
2
- import { createWithTransaction } from "@jskit-ai/database-runtime/shared";
3
2
 
4
3
  const hash = (value) => createHash("sha256").update(value).digest("hex");
5
4
 
@@ -10,12 +9,10 @@ function createKnexConnectionStore({ knex, protection }) {
10
9
  if (typeof protection?.seal !== "function" || typeof protection?.open !== "function") {
11
10
  throw new TypeError("Connector storage requires credential protection.");
12
11
  }
13
- const transaction = createWithTransaction(knex);
14
-
15
12
  async function withConnection({ owner, integrationId }, work) {
16
13
  const connectionKey = hash(JSON.stringify([owner.applicationId, owner.subjectId, integrationId]));
17
14
  const connectionBinding = `connection:${connectionKey}`;
18
- return transaction(async (trx) => {
15
+ return knex.transaction(async (trx) => {
19
16
  // Retain this row on disconnect so other processes continue to lock the same identity.
20
17
  await trx("connector_connections").insert({ connection_key: connectionKey })
21
18
  .onConflict("connection_key").merge({ connection_key: connectionKey });
@@ -54,6 +54,22 @@ class IntegrationConfigurationError extends Error {
54
54
  }
55
55
  }
56
56
 
57
+ // Registrations, attempts and stored connections omit these when they use the defaults.
58
+ const DEFAULT_GRANT_TYPE = "authorization_code";
59
+ const DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_post";
60
+
61
+ function registrationGrantType(record) {
62
+ return record?.grantType || DEFAULT_GRANT_TYPE;
63
+ }
64
+
65
+ function registrationClientAuthentication(record) {
66
+ return record?.tokenEndpointAuthMethod || DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD;
67
+ }
68
+
69
+ function providerGrantTypes(provider) {
70
+ return provider.oauthGrantTypes || [DEFAULT_GRANT_TYPE];
71
+ }
72
+
57
73
  function getProviderSettingsSchema(provider, settings = {}) {
58
74
  return typeof provider.settingsSchema === "function" ? provider.settingsSchema(settings) : provider.settingsSchema;
59
75
  }
@@ -66,15 +82,28 @@ function getProviderAccountModes(provider, settings = {}) {
66
82
  return provider.accountModesForSettings ? provider.accountModesForSettings(settings) : provider.accountModes;
67
83
  }
68
84
 
69
- function getProviderScopes(provider, settings = {}, grantType = "authorization_code", authenticationMethod) {
85
+ function getProviderScopes(provider, settings = {}, grantType = DEFAULT_GRANT_TYPE, authenticationMethod) {
70
86
  const scopes = provider.scopesForGrantType ? provider.scopesForGrantType(grantType, settings)
71
87
  : provider.scopesForSettings ? provider.scopesForSettings(settings) : provider.scopes;
72
88
  return authenticationMethod ? scopes.filter(scope => !scope.authenticationMethods || scope.authenticationMethods.includes(authenticationMethod)) : scopes;
73
89
  }
74
90
 
75
- function getProviderClientAuthenticationMethods(provider, grantType = "authorization_code", settings = {}) {
91
+ function getProviderClientAuthenticationMethods(provider, grantType = DEFAULT_GRANT_TYPE, settings = {}) {
76
92
  const methods = provider.oauthClientAuthenticationMethods;
77
- return typeof methods === "function" ? methods(grantType, settings) : methods || ["client_secret_post"];
93
+ return typeof methods === "function" ? methods(grantType, settings) : methods || [DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD];
94
+ }
95
+
96
+ // Setup text precedence: settings-dependent, then per-authentication-method, then static.
97
+ function getProviderSetup(provider, settings = {}, authenticationMethod) {
98
+ const setup = provider.setup || {};
99
+ return {
100
+ url: setup.urlForSettings?.(settings, authenticationMethod) || setup.urlByAuthentication?.[authenticationMethod] || setup.url,
101
+ steps: setup.stepsForSettings?.(settings, authenticationMethod) || setup.stepsByAuthentication?.[authenticationMethod] || setup.steps
102
+ };
103
+ }
104
+
105
+ function isProviderConfigurationOnly(provider, settings = {}) {
106
+ return Boolean(provider.configurationOnly || provider.configurationOnlyForSettings?.(settings));
78
107
  }
79
108
 
80
109
  function validateIntegrationConfiguration(input, { providers = [], allowUnknownProviders = false } = {}) {
@@ -117,7 +146,7 @@ function validateIntegrationConfiguration(input, { providers = [], allowUnknownP
117
146
  const auth = integration.authentication;
118
147
  const provider = definitions.get(integration.provider);
119
148
  const registration = config.registrations[auth.registrationRef];
120
- const grantType = registration?.grantType || "authorization_code";
149
+ const grantType = registrationGrantType(registration);
121
150
  if (auth.refreshTokenRef && auth.method !== "oauth2") {
122
151
  fieldErrors[`${path}.authentication.refreshTokenRef`] = "Only an OAuth connection can start from a refresh token.";
123
152
  }
@@ -181,11 +210,11 @@ function validateIntegrationConfiguration(input, { providers = [], allowUnknownP
181
210
  }
182
211
  if (auth.method === "oauth2" && registration?.source === "own") {
183
212
  const registrationPath = `registrations.${auth.registrationRef}`;
184
- if (!(provider.oauthGrantTypes || ["authorization_code"]).includes(grantType)) {
213
+ if (!providerGrantTypes(provider).includes(grantType)) {
185
214
  fieldErrors[`${registrationPath}.grantType`] = "This provider does not support this OAuth flow.";
186
215
  }
187
216
  const methods = getProviderClientAuthenticationMethods(provider, grantType, integration.settings || {});
188
- if (!methods.includes(registration.tokenEndpointAuthMethod || "client_secret_post")) {
217
+ if (!methods.includes(registrationClientAuthentication(registration))) {
189
218
  fieldErrors[`${registrationPath}.tokenEndpointAuthMethod`] = "This provider does not support this client authentication method.";
190
219
  }
191
220
  if (registration.clientId && provider.validateClientId) {
@@ -221,11 +250,18 @@ function parseIntegrationConfiguration(text, options) {
221
250
  }
222
251
 
223
252
  export {
253
+ DEFAULT_GRANT_TYPE,
254
+ DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD,
255
+ registrationGrantType,
256
+ registrationClientAuthentication,
257
+ providerGrantTypes,
224
258
  getProviderAuthenticationMethods,
225
259
  getProviderClientAuthenticationMethods,
226
260
  getProviderAccountModes,
227
261
  getProviderScopes,
228
262
  getProviderSettingsSchema,
263
+ getProviderSetup,
264
+ isProviderConfigurationOnly,
229
265
  secretReference,
230
266
  integrationsSchema,
231
267
  IntegrationConfigurationError,
@@ -79,6 +79,26 @@ test("credential-free grants require verification, authorize access and invalida
79
79
  assert.equal((await restart.status(input)).status, "disconnected");
80
80
  });
81
81
 
82
+ test("result validation sees the request its operation built and rejects a mismatched answer", async () => {
83
+ const check = clickhouseProvider.operations["connection.check"];
84
+ const seen = [];
85
+ let expectedUser = "default";
86
+ const provider = { ...clickhouseProvider, operations: { ...clickhouseProvider.operations, "connection.check": { ...check,
87
+ validateResult: (value, request) => { seen.push(request); return check.validateResult(value) && value.data[0].user === expectedUser; } } } };
88
+ const service = createConnectionService({
89
+ configuration: databaseConfiguration({ method: "none" }), providers: [provider], store: memoryStore(),
90
+ authorize: async (context) => context, resolveReference: async () => "secret",
91
+ fetchImpl: async () => Response.json({ meta: [], data: [{ ok: 1, user: "default" }], rows: 1 })
92
+ });
93
+ const input = { context: owner, integrationId: "database" };
94
+ await service.connectWithoutCredentials(input);
95
+ const built = check.request({}, { httpUrl: "https://database.example:8443/" });
96
+ assert.equal(seen[0].url, built.url);
97
+ assert.equal(seen[0].method, built.method);
98
+ expectedUser = "someone-else";
99
+ await assert.rejects(service.invoke({ ...input, operation: "connection.check" }), { code: "connector_response_invalid", statusCode: 502 });
100
+ });
101
+
82
102
  test("the ordinary credential-free Feature action verifies without resolving references", async () => {
83
103
  const contexts = [];
84
104
  let actions;
@@ -735,6 +755,16 @@ test("invalid operation input remains a field error and never reaches the provid
735
755
  return true;
736
756
  });
737
757
  assert.equal(requests.length, count);
758
+ // Preflight verification input reports the same field error before consent opens.
759
+ await assert.rejects(service.beginAuthorization({
760
+ context: owner, integrationId: "calendar", verificationInput: { maxResults: -1 }
761
+ }), (error) => {
762
+ assert.equal(error.code, "connector_input_invalid");
763
+ assert.equal(error.statusCode, 422);
764
+ assert.ok(error.fieldErrors.maxResults);
765
+ return true;
766
+ });
767
+ assert.equal(requests.length, count);
738
768
  });
739
769
 
740
770
  test("saved grants cannot override permissions removed from application source", async () => {
@@ -0,0 +1,28 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { getProviderSetup, isProviderConfigurationOnly } from "../src/shared/configuration.js";
4
+
5
+ const provider = {
6
+ configurationOnlyForSettings: (settings) => settings.mode === "public",
7
+ setup: {
8
+ url: "https://example.test/token", steps: ["Create a token."],
9
+ urlByAuthentication: { oauth2: "https://example.test/oauth" }, stepsByAuthentication: { oauth2: ["Register a client."] },
10
+ urlForSettings: (settings) => settings.mode === "mcp" ? "https://example.test/mcp" : undefined,
11
+ stepsForSettings: (settings, method) => settings.mode === "mcp" ? [`Register an MCP client for ${method}.`] : undefined
12
+ }
13
+ };
14
+
15
+ test("provider setup resolves settings, then authentication method, then static text", () => {
16
+ assert.deepEqual(getProviderSetup(provider, {}, "api-key"), { url: "https://example.test/token", steps: ["Create a token."] });
17
+ assert.deepEqual(getProviderSetup(provider, {}, "oauth2"), { url: "https://example.test/oauth", steps: ["Register a client."] });
18
+ assert.deepEqual(getProviderSetup(provider, { mode: "mcp" }, "oauth2"),
19
+ { url: "https://example.test/mcp", steps: ["Register an MCP client for oauth2."] });
20
+ assert.deepEqual(getProviderSetup({ setup: { url: "https://example.test", steps: [] } }),
21
+ { url: "https://example.test", steps: [] });
22
+ });
23
+
24
+ test("configuration-only providers resolve static and settings-dependent flags", () => {
25
+ assert.equal(isProviderConfigurationOnly({ configurationOnly: true }), true);
26
+ assert.equal(isProviderConfigurationOnly(provider, { mode: "public" }), true);
27
+ assert.equal(isProviderConfigurationOnly(provider, {}), false);
28
+ });