@jskit-ai/connectors-core 0.1.61 → 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.61",
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.244",
57
- "@jskit-ai/http-runtime": "0.1.242",
58
- "@jskit-ai/kernel": "0.1.244"
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) {
@@ -13,10 +13,31 @@ function publicConnection(connection) {
13
13
  integrationId: connection.integrationId,
14
14
  grantedScopes: [...connection.grantedScopes],
15
15
  verifiedAt: connection.verifiedAt,
16
- ...(connection.accountLabel ? { accountLabel: connection.accountLabel } : {})
16
+ ...(connection.accountLabel ? { accountLabel: connection.accountLabel } : {}),
17
+ ...(connection.source ? { source: connection.source } : {})
17
18
  };
18
19
  }
19
20
 
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
+ }
40
+
20
41
  function validateCallbackUrl(value) {
21
42
  let url;
22
43
  try { url = new URL(value); } catch { /* reported below */ }
@@ -27,6 +48,29 @@ function validateCallbackUrl(value) {
27
48
  return url.href;
28
49
  }
29
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
+
30
74
  function createConnectionService({
31
75
  configuration,
32
76
  providers,
@@ -102,8 +146,8 @@ function createConnectionService({
102
146
  if (registration.clientId.trim() === "MISSING") {
103
147
  throw new ConnectorError("connector_binding_missing", "The provider client ID is not configured.");
104
148
  }
105
- const grantType = registration.grantType || "authorization_code";
106
- const tokenEndpointAuthMethod = registration.tokenEndpointAuthMethod || "client_secret_post";
149
+ const grantType = registrationGrantType(registration);
150
+ const tokenEndpointAuthMethod = registrationClientAuthentication(registration);
107
151
  if (provider.oauthPkce === false && tokenEndpointAuthMethod === "none") {
108
152
  throw new ConnectorError("connector_mode_unavailable", "This provider's non-PKCE flow requires a confidential client.");
109
153
  }
@@ -166,6 +210,38 @@ function createConnectionService({
166
210
  }
167
211
  }
168
212
 
213
+ // An Env refresh token seeds the shared connection; the app then refreshes
214
+ // and stores rotated tokens itself, and reseeds when the value or the OAuth
215
+ // client changes.
216
+ async function envRefreshTokenFor(integration) {
217
+ try {
218
+ const value = await resolveReference(integration.authentication.refreshTokenRef);
219
+ const refreshToken = typeof value === "string" ? value.trim() : "";
220
+ if (!refreshToken || refreshToken === "MISSING" || /\s/u.test(refreshToken)) throw new Error("Invalid token.");
221
+ return { refreshToken, fingerprint: createHash("sha256").update(refreshToken).digest("hex") };
222
+ } catch {
223
+ throw new ConnectorError("connector_binding_missing", "The refresh token binding is missing or invalid.");
224
+ }
225
+ }
226
+
227
+ function envSeedIsCurrent(connection, seed, registration) {
228
+ return connection?.seedFingerprint === seed.fingerprint && connection.clientId === registration.client.client_id &&
229
+ connection.registrationRef === registration.registrationRef &&
230
+ connection.tokenEndpointAuthMethod === registration.tokenEndpointAuthMethod;
231
+ }
232
+
233
+ function envSeededConnection(integrationId, integration, provider, registration, seed) {
234
+ return {
235
+ integrationId, provider: provider.id, registrationRef: registration.registrationRef,
236
+ grantType: registration.grantType, clientId: registration.client.client_id,
237
+ tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod, status: "connected", source: "env",
238
+ seedFingerprint: seed.fingerprint, verifiedAt: now(), settings: structuredClone(integration.settings || {}),
239
+ ...(provider.refreshRequiresRedirectUri ? { callbackUrl: registration.callbackUrl } : {}),
240
+ tokens: { accessToken: null, refreshToken: seed.refreshToken, expiresAt: 0 },
241
+ grantedScopes: [...integration.scopes]
242
+ };
243
+ }
244
+
169
245
  async function serviceAccountGrant(integration, provider, binding, signal) {
170
246
  if (typeof provider.serviceAccountGrant !== "function") {
171
247
  throw new ConnectorError("connector_mode_unavailable", "This provider does not implement service-account authorization.");
@@ -200,11 +276,8 @@ function createConnectionService({
200
276
  if (integration.authentication.method !== "service-account" || typeof provider.serviceAccountGrant !== "function") {
201
277
  throw new ConnectorError("connector_mode_unavailable", "Select service-account credentials to verify this connection.");
202
278
  }
203
- provider.operations[provider.checkOperation].request(verificationInput, integration.settings || {});
204
- const required = provider.operations[provider.checkOperation].scopes;
205
- if (required.length && !required.some((scope) => integration.scopes.includes(scope))) {
206
- throw new ConnectorError("connector_scope_missing", "Connection verification requires a permission absent from the application's configuration.", { statusCode: 403 });
207
- }
279
+ operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
280
+ requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE);
208
281
  return store.withConnection({ owner, integrationId }, async ({ save }) => {
209
282
  try {
210
283
  const binding = await serviceAccountCredentialFor(integration);
@@ -238,17 +311,11 @@ function createConnectionService({
238
311
  if (!operation || !Object.hasOwn(provider.operations, operationId)) {
239
312
  throw new ConnectorError("connector_operation_unknown", "This provider operation is not available.");
240
313
  }
241
- if (["oauth2", "service-account"].includes(connection.method || "oauth2") && operation.scopes.length && !operation.scopes.some((scope) => connection.grantedScopes.includes(scope))) {
242
- 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.");
243
316
  }
244
- let request;
245
- try { request = operation.request(input, connection.settings || {}, connection.providerData); } catch (error) {
246
- if (!error.fieldErrors) throw error;
247
- const failure = new ConnectorError("connector_input_invalid", "Check the operation's input values.", { statusCode: 422 });
248
- failure.fieldErrors = error.fieldErrors;
249
- throw failure;
250
- }
251
- const url = new URL(request.url);
317
+ const descriptor = operationRequest(operation, input, connection.settings, connection.providerData);
318
+ const url = new URL(descriptor.url);
252
319
  const origins = typeof provider.apiOrigins === "function" ? provider.apiOrigins(connection.settings || {}, connection.providerData) : provider.apiOrigins;
253
320
  if (url.protocol !== "https:" || url.username || url.password || !origins.includes(url.origin)) {
254
321
  throw new ConnectorError("connector_destination_invalid", "The provider operation has an invalid destination.");
@@ -265,17 +332,17 @@ function createConnectionService({
265
332
  if (connection.method === "api-key" && provider.apiKey.queryParameter) {
266
333
  url.searchParams.set(provider.apiKey.queryParameter, connection.apiKey);
267
334
  }
268
- let body = request.body;
335
+ let body = descriptor.body;
269
336
  if (connection.method === "api-key" && provider.apiKey.bodyParameter) {
270
- 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)) {
271
338
  throw new ConnectorError("connector_request_invalid", "This provider requires a JSON request body.");
272
339
  }
273
340
  body = { ...body, [provider.apiKey.bodyParameter]: connection.apiKey };
274
341
  }
275
342
  const options = {
276
- method: request.method,
343
+ method: descriptor.method,
277
344
  body,
278
- headers: { ...request.headers, Accept: "application/json", ...(connection.method === "api-key"
345
+ headers: { ...descriptor.headers, Accept: "application/json", ...(connection.method === "api-key"
279
346
  ? provider.apiKey.headers?.(connection.apiKey, connection.settings || {})
280
347
  : connection.method === "none" ? {} : {
281
348
  ...provider.oauthHeaders?.({ clientId: connection.clientId }), Authorization: `Bearer ${connection.tokens.accessToken}`
@@ -294,7 +361,8 @@ function createConnectionService({
294
361
  options.signal.throwIfAborted();
295
362
  throw error;
296
363
  }
297
- 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)) {
298
366
  throw new ConnectorError("connector_response_invalid", "The provider returned an unexpected response.", { statusCode: 502 });
299
367
  }
300
368
  return result;
@@ -329,10 +397,7 @@ function createConnectionService({
329
397
  throw new ConnectorError("connector_response_invalid", "The provider returned an invalid permission grant.", { statusCode: 502 });
330
398
  }
331
399
  connection.grantedScopes = connection.grantedScopes.filter((scope) => requestedScopes.includes(scope) && scopes.includes(scope));
332
- const required = provider.operations[provider.checkOperation].scopes;
333
- if (required.length && !required.some((scope) => connection.grantedScopes.includes(scope))) {
334
- throw new ConnectorError("connector_scope_missing", "Connect again with the permission required for verification.", { statusCode: 403 });
335
- }
400
+ requireOperationScope(provider.operations[provider.checkOperation], connection.grantedScopes, "Connect again with the permission required for verification.");
336
401
  }
337
402
 
338
403
  async function clientCredentialsGrant(registration, provider, settings, scopes, signal) {
@@ -357,11 +422,8 @@ function createConnectionService({
357
422
  if (registration.grantType !== "client_credentials") {
358
423
  throw new ConnectorError("connector_mode_unavailable", "Select client credentials to connect a service account.");
359
424
  }
360
- provider.operations[provider.checkOperation].request(verificationInput, integration.settings || {});
361
- const required = provider.operations[provider.checkOperation].scopes;
362
- if (required.length && !required.some((scope) => integration.scopes.includes(scope))) {
363
- throw new ConnectorError("connector_scope_missing", "Connection verification requires a permission absent from the application's configuration.", { statusCode: 403 });
364
- }
425
+ operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
426
+ requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE);
365
427
  return store.withConnection({ owner, integrationId }, async ({ save }) => {
366
428
  try {
367
429
  const connection = {
@@ -380,8 +442,11 @@ function createConnectionService({
380
442
 
381
443
  async function beginAuthorization({ context, integrationId, verificationInput = {}, signal }) {
382
444
  const { owner, integration, provider } = await access(context, integrationId, "connect");
445
+ if (integration.authentication.refreshTokenRef) {
446
+ throw new ConnectorError("connector_env_managed", ENV_SEEDED_MESSAGE, { statusCode: 409 });
447
+ }
383
448
  // Resource-specific providers validate the first document ID before opening consent.
384
- provider.operations[provider.checkOperation].request(verificationInput, integration.settings || {});
449
+ operationRequest(provider.operations[provider.checkOperation], verificationInput, integration.settings);
385
450
  const registration = await registrationFor(integration, provider);
386
451
  if (registration.grantType !== "authorization_code") {
387
452
  throw new ConnectorError("connector_mode_unavailable", "Service accounts connect without browser consent.");
@@ -456,8 +521,8 @@ function createConnectionService({
456
521
  if (!attempt || attempt.expiresAt <= now() || attempt.provider !== provider.id ||
457
522
  (attempt.codeVerifier === null) !== (provider.oauthPkce === false) ||
458
523
  attempt.clientId !== registration.client.client_id || attempt.registrationRef !== registration.registrationRef ||
459
- (attempt.grantType || "authorization_code") !== registration.grantType ||
460
- (attempt.tokenEndpointAuthMethod || "client_secret_post") !== registration.tokenEndpointAuthMethod ||
524
+ registrationGrantType(attempt) !== registration.grantType ||
525
+ registrationClientAuthentication(attempt) !== registration.tokenEndpointAuthMethod ||
461
526
  attempt.callbackUrl !== registration.callbackUrl || JSON.stringify(attempt.scopes) !== JSON.stringify(integration.scopes) ||
462
527
  !isDeepStrictEqual(attempt.settings || {}, integration.settings || {})) {
463
528
  throw new ConnectorError("connector_attempt_invalid", "The authorization attempt is invalid or has expired.");
@@ -507,10 +572,7 @@ function createConnectionService({
507
572
  !provider.apiKey?.bodyParameter && typeof provider.apiKey?.pathPrefix !== "function"))) {
508
573
  throw new ConnectorError("connector_mode_unavailable", "Select the matching credential mode to verify this connection.");
509
574
  }
510
- const requiredScopes = provider.operations[provider.checkOperation].scopes;
511
- if (requiredScopes.length && !requiredScopes.some((scope) => integration.scopes.includes(scope))) {
512
- throw new ConnectorError("connector_scope_missing", "Connection verification requires a permission absent from the application's configuration.", { statusCode: 403 });
513
- }
575
+ requireOperationScope(provider.operations[provider.checkOperation], integration.scopes, VERIFICATION_SCOPE_MESSAGE);
514
576
  return store.withConnection({ owner, integrationId }, async ({ save }) => {
515
577
  const connection = {
516
578
  integrationId, provider: provider.id, method,
@@ -537,24 +599,20 @@ function createConnectionService({
537
599
  if (!Object.hasOwn(provider.operations, operation)) {
538
600
  throw new ConnectorError("connector_operation_unknown", "This provider operation is not available.");
539
601
  }
540
- if (provider.operations[operation].scopes.length && !provider.operations[operation].scopes.some((scope) => integration.scopes.includes(scope))) {
541
- throw new ConnectorError("connector_scope_missing", "This operation requires a permission absent from the application's configuration.", { statusCode: 403 });
542
- }
602
+ requireOperationScope(provider.operations[operation], integration.scopes, "This operation requires a permission absent from the application's configuration.");
543
603
  const usesApiKey = integration.authentication.method === "api-key";
544
604
  const usesServiceAccount = integration.authentication.method === "service-account";
545
605
  const usesOAuth = integration.authentication.method === "oauth2";
546
606
  const registration = usesOAuth ? await registrationFor(integration, provider) : null;
607
+ const seed = integration.authentication.refreshTokenRef ? await envRefreshTokenFor(integration) : null;
547
608
  const outcome = await store.withConnection({ owner, integrationId }, async ({ connection, save }) => {
548
- if (!connection || connection.status !== "connected" || connection.provider !== provider.id ||
549
- (connection.method || "oauth2") !== integration.authentication.method ||
550
- !isDeepStrictEqual(connection.settings || {}, integration.settings || {}) ||
551
- ((usesApiKey || usesServiceAccount) && connection.secretRef !== integration.authentication.secretRef) ||
552
- (usesServiceAccount && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
553
- (usesOAuth && (connection.registrationRef !== registration.registrationRef || connection.clientId !== registration.client.client_id ||
554
- (connection.grantType || "authorization_code") !== registration.grantType ||
555
- (registration.grantType === "client_credentials" && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
556
- (registration.grantType === "authorization_code" && provider.refreshRequiresRedirectUri && connection.callbackUrl !== registration.callbackUrl) ||
557
- (connection.tokenEndpointAuthMethod || "client_secret_post") !== registration.tokenEndpointAuthMethod))) {
609
+ if (seed && !envSeedIsCurrent(connection, seed, registration)) {
610
+ connection = envSeededConnection(integrationId, integration, provider, registration, seed);
611
+ await save(connection);
612
+ }
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))) {
558
616
  throw new ConnectorError("connector_reconnect_required", "Connect this account again.", { statusCode: 401 });
559
617
  }
560
618
  try {
@@ -597,8 +655,11 @@ function createConnectionService({
597
655
  });
598
656
  return { result };
599
657
  } catch (error) {
600
- const failure = providerError(error);
601
- if (failure.code === "connector_reconnect_required") await save({ ...connection, status: "reconnect-required" });
658
+ let failure = providerError(error);
659
+ if (failure.code === "connector_reconnect_required") {
660
+ await save({ ...connection, status: "reconnect-required" });
661
+ if (seed) failure = new ConnectorError(failure.code, "The refresh token in Env no longer works; replace that Env value.", { statusCode: 401 });
662
+ }
602
663
  // Commit rotated tokens and reconnect state even when the API request fails.
603
664
  return { failure };
604
665
  }
@@ -609,17 +670,14 @@ function createConnectionService({
609
670
 
610
671
  async function status({ context, integrationId }) {
611
672
  const { owner, integration, provider } = await access(context, integrationId, "status");
673
+ // Compare against the configured registration without resolving its secrets.
612
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
+ };
613
679
  return store.withConnection({ owner, integrationId }, async ({ connection }) => {
614
- if (connection && (connection.provider !== integration.provider ||
615
- (connection.method || "oauth2") !== integration.authentication.method ||
616
- !isDeepStrictEqual(connection.settings || {}, integration.settings || {}) ||
617
- (["api-key", "service-account"].includes(integration.authentication.method) && connection.secretRef !== integration.authentication.secretRef) ||
618
- (integration.authentication.method === "service-account" && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
619
- (integration.authentication.method === "oauth2" && (connection.registrationRef !== integration.authentication.registrationRef || connection.clientId !== registration?.clientId ||
620
- (connection.grantType || "authorization_code") !== (registration?.grantType || "authorization_code") ||
621
- (registration?.grantType === "client_credentials" && !isDeepStrictEqual(connection.requestedScopes, integration.scopes)) ||
622
- (connection.tokenEndpointAuthMethod || "client_secret_post") !== (registration?.tokenEndpointAuthMethod || "client_secret_post"))))) {
680
+ if (connection && !integration.authentication.refreshTokenRef && !connectionMatches(connection, integration, integration.provider, configured)) {
623
681
  return publicConnection({ ...connection, status: "reconnect-required" });
624
682
  }
625
683
  let callbackUrl;
@@ -627,8 +685,14 @@ function createConnectionService({
627
685
  if (integration.authentication.method === "oauth2") {
628
686
  const resolved = await registrationFor(integration, provider);
629
687
  callbackUrl = resolved.callbackUrl;
630
- if (connection && provider.refreshRequiresRedirectUri && resolved.grantType === "authorization_code" &&
631
- connection.callbackUrl !== resolved.callbackUrl) {
688
+ if (integration.authentication.refreshTokenRef) {
689
+ const seed = await envRefreshTokenFor(integration);
690
+ // A new value is used on the next call; until then it reads as connected from Env.
691
+ if (!envSeedIsCurrent(connection, seed, resolved)) {
692
+ return { status: "connected", source: "env", provider: provider.id, integrationId, grantedScopes: [...integration.scopes], callbackUrl };
693
+ }
694
+ }
695
+ if (connection && !callbackMatches(connection, provider, resolved)) {
632
696
  return publicConnection({ ...connection, status: "reconnect-required" });
633
697
  }
634
698
  } else if (integration.authentication.method === "api-key") {
@@ -647,7 +711,10 @@ function createConnectionService({
647
711
  }
648
712
 
649
713
  async function disconnect({ context, integrationId }) {
650
- const { owner } = await access(context, integrationId, "disconnect");
714
+ const { owner, integration } = await access(context, integrationId, "disconnect");
715
+ if (integration.authentication.refreshTokenRef) {
716
+ throw new ConnectorError("connector_env_managed", ENV_SEEDED_MESSAGE, { statusCode: 409 });
717
+ }
651
718
  await store.withConnection({ owner, integrationId }, async ({ remove }) => remove());
652
719
  return { status: "disconnected" };
653
720
  }
@@ -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 });
@@ -9,7 +9,8 @@ const secretReference = {
9
9
  const authenticationSchema = createSchema({
10
10
  method: { ...name, enum: ["oauth2", "api-key", "service-account", "none"] },
11
11
  registrationRef: { ...name, required: false },
12
- secretRef: { ...secretReference, required: false }
12
+ secretRef: { ...secretReference, required: false },
13
+ refreshTokenRef: { ...secretReference, required: false }
13
14
  });
14
15
  const assistantPermission = { type: "string", enum: ["ask", "always", "never"] };
15
16
  const assistantPolicySchema = createSchema({
@@ -53,6 +54,22 @@ class IntegrationConfigurationError extends Error {
53
54
  }
54
55
  }
55
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
+
56
73
  function getProviderSettingsSchema(provider, settings = {}) {
57
74
  return typeof provider.settingsSchema === "function" ? provider.settingsSchema(settings) : provider.settingsSchema;
58
75
  }
@@ -65,15 +82,28 @@ function getProviderAccountModes(provider, settings = {}) {
65
82
  return provider.accountModesForSettings ? provider.accountModesForSettings(settings) : provider.accountModes;
66
83
  }
67
84
 
68
- function getProviderScopes(provider, settings = {}, grantType = "authorization_code", authenticationMethod) {
85
+ function getProviderScopes(provider, settings = {}, grantType = DEFAULT_GRANT_TYPE, authenticationMethod) {
69
86
  const scopes = provider.scopesForGrantType ? provider.scopesForGrantType(grantType, settings)
70
87
  : provider.scopesForSettings ? provider.scopesForSettings(settings) : provider.scopes;
71
88
  return authenticationMethod ? scopes.filter(scope => !scope.authenticationMethods || scope.authenticationMethods.includes(authenticationMethod)) : scopes;
72
89
  }
73
90
 
74
- function getProviderClientAuthenticationMethods(provider, grantType = "authorization_code", settings = {}) {
91
+ function getProviderClientAuthenticationMethods(provider, grantType = DEFAULT_GRANT_TYPE, settings = {}) {
75
92
  const methods = provider.oauthClientAuthenticationMethods;
76
- 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));
77
107
  }
78
108
 
79
109
  function validateIntegrationConfiguration(input, { providers = [], allowUnknownProviders = false } = {}) {
@@ -116,12 +146,20 @@ function validateIntegrationConfiguration(input, { providers = [], allowUnknownP
116
146
  const auth = integration.authentication;
117
147
  const provider = definitions.get(integration.provider);
118
148
  const registration = config.registrations[auth.registrationRef];
119
- const grantType = registration?.grantType || "authorization_code";
149
+ const grantType = registrationGrantType(registration);
150
+ if (auth.refreshTokenRef && auth.method !== "oauth2") {
151
+ fieldErrors[`${path}.authentication.refreshTokenRef`] = "Only an OAuth connection can start from a refresh token.";
152
+ }
120
153
  if (auth.method === "oauth2") {
121
154
  if (!auth.registrationRef || !Object.hasOwn(config.registrations, auth.registrationRef)) {
122
155
  fieldErrors[`${path}.authentication.registrationRef`] = "Select a registration defined in this file.";
123
156
  }
124
157
  if (auth.secretRef) fieldErrors[`${path}.authentication.secretRef`] = "Use the registration's secret reference.";
158
+ if (auth.refreshTokenRef && integration.accountMode !== "shared") {
159
+ fieldErrors[`${path}.authentication.refreshTokenRef`] = "Only a shared connection can start from a refresh token in Env.";
160
+ } else if (auth.refreshTokenRef && grantType !== "authorization_code") {
161
+ fieldErrors[`${path}.authentication.refreshTokenRef`] = "Client credentials do not use a refresh token.";
162
+ }
125
163
  if (grantType === "client_credentials" && integration.accountMode === "per-user") {
126
164
  fieldErrors[`${path}.accountMode`] = "A service account cannot connect as each app user.";
127
165
  }
@@ -172,11 +210,11 @@ function validateIntegrationConfiguration(input, { providers = [], allowUnknownP
172
210
  }
173
211
  if (auth.method === "oauth2" && registration?.source === "own") {
174
212
  const registrationPath = `registrations.${auth.registrationRef}`;
175
- if (!(provider.oauthGrantTypes || ["authorization_code"]).includes(grantType)) {
213
+ if (!providerGrantTypes(provider).includes(grantType)) {
176
214
  fieldErrors[`${registrationPath}.grantType`] = "This provider does not support this OAuth flow.";
177
215
  }
178
216
  const methods = getProviderClientAuthenticationMethods(provider, grantType, integration.settings || {});
179
- if (!methods.includes(registration.tokenEndpointAuthMethod || "client_secret_post")) {
217
+ if (!methods.includes(registrationClientAuthentication(registration))) {
180
218
  fieldErrors[`${registrationPath}.tokenEndpointAuthMethod`] = "This provider does not support this client authentication method.";
181
219
  }
182
220
  if (registration.clientId && provider.validateClientId) {
@@ -212,11 +250,18 @@ function parseIntegrationConfiguration(text, options) {
212
250
  }
213
251
 
214
252
  export {
253
+ DEFAULT_GRANT_TYPE,
254
+ DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD,
255
+ registrationGrantType,
256
+ registrationClientAuthentication,
257
+ providerGrantTypes,
215
258
  getProviderAuthenticationMethods,
216
259
  getProviderClientAuthenticationMethods,
217
260
  getProviderAccountModes,
218
261
  getProviderScopes,
219
262
  getProviderSettingsSchema,
263
+ getProviderSetup,
264
+ isProviderConfigurationOnly,
220
265
  secretReference,
221
266
  integrationsSchema,
222
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
+ });
@@ -0,0 +1,152 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { googleCalendarProvider } from "../../connector-google-calendar/src/server/provider.js";
4
+ import { createConnectionService, createEnvironmentReferenceResolver } from "../src/server/index.js";
5
+ import { validateIntegrationConfiguration } from "../src/shared/configuration.js";
6
+
7
+ const listScope = "https://www.googleapis.com/auth/calendar.calendarlist.readonly";
8
+ const owner = { applicationId: "app-1", subjectId: "app" };
9
+ const integrationId = "calendar";
10
+
11
+ function configuration({ accountMode = "shared", method = "oauth2", refreshTokenRef = "env:GOOGLE_REFRESH_TOKEN", grantType } = {}) {
12
+ return {
13
+ schemaVersion: 1,
14
+ integrations: {
15
+ calendar: {
16
+ provider: "google-calendar", accountMode, scopes: [listScope],
17
+ authentication: method === "oauth2"
18
+ ? { method, registrationRef: "google", ...(refreshTokenRef ? { refreshTokenRef } : {}) }
19
+ : { method, secretRef: "env:KEY", refreshTokenRef },
20
+ settings: {}
21
+ }
22
+ },
23
+ registrations: {
24
+ google: {
25
+ source: "own", clientId: "client-1", clientSecretRef: "env:GOOGLE_SECRET",
26
+ ...(grantType ? { grantType, tokenEndpointAuthMethod: "client_secret_basic" } : { callbackUrlRef: "env:CALLBACK" })
27
+ }
28
+ }
29
+ };
30
+ }
31
+
32
+ function memoryStore() {
33
+ const connections = new Map();
34
+ return {
35
+ connections,
36
+ async withConnection({ integrationId: id }, run) {
37
+ let pending = structuredClone(connections.get(id) || null);
38
+ const result = await run({
39
+ connection: pending,
40
+ save: async (value) => { pending = structuredClone(value); },
41
+ remove: async () => { pending = null; },
42
+ putAttempt: async () => {},
43
+ consumeAttempt: async () => null
44
+ });
45
+ if (pending) connections.set(id, pending);
46
+ else connections.delete(id);
47
+ return result;
48
+ }
49
+ };
50
+ }
51
+
52
+ function setup({ env = { GOOGLE_REFRESH_TOKEN: "seed-token-1" } } = {}) {
53
+ const variables = { GOOGLE_SECRET: "client-secret", CALLBACK: "http://127.0.0.1:8080/callback", ...env };
54
+ const store = memoryStore();
55
+ const refreshes = [];
56
+ const revoked = new Set();
57
+ let time = 1_000_000;
58
+ const service = createConnectionService({
59
+ configuration: configuration(),
60
+ providers: [googleCalendarProvider],
61
+ store,
62
+ authorize: async (context) => context,
63
+ resolveReference: createEnvironmentReferenceResolver(variables),
64
+ now: () => time,
65
+ async fetchImpl(url, init) {
66
+ if (String(url) === "https://oauth2.googleapis.com/token") {
67
+ const body = new URLSearchParams(init.body);
68
+ const refreshToken = body.get("refresh_token");
69
+ refreshes.push(refreshToken);
70
+ if (revoked.has(refreshToken)) return Response.json({ error: "invalid_grant" }, { status: 400 });
71
+ return Response.json({
72
+ token_type: "Bearer", access_token: `access-${refreshes.length}`, refresh_token: `rotated-${refreshes.length}`,
73
+ expires_in: 3600, scope: listScope
74
+ });
75
+ }
76
+ return Response.json({ kind: "calendar#calendarList", items: [] });
77
+ }
78
+ });
79
+ const call = () => service.invoke({ context: owner, integrationId, operation: googleCalendarProvider.checkOperation });
80
+ return { service, store, variables, refreshes, revoked, call, advance: (ms) => { time += ms; } };
81
+ }
82
+
83
+ test("refreshTokenRef is accepted only on a shared OAuth integration with a browser registration", () => {
84
+ const validate = (config) => validateIntegrationConfiguration(config, { providers: [googleCalendarProvider] });
85
+ assert.equal(validate(configuration()).integrations.calendar.authentication.refreshTokenRef, "env:GOOGLE_REFRESH_TOKEN");
86
+ const refused = [
87
+ [configuration({ accountMode: "per-user" }), "Only a shared connection can start from a refresh token in Env."],
88
+ [configuration({ grantType: "client_credentials" }), "Client credentials do not use a refresh token."],
89
+ [configuration({ method: "api-key" }), "Only an OAuth connection can start from a refresh token."]
90
+ ];
91
+ for (const [config, message] of refused) {
92
+ assert.throws(() => validate(config), (error) => error.fieldErrors["integrations.calendar.authentication.refreshTokenRef"] === message);
93
+ }
94
+ assert.throws(() => validate(configuration({ refreshTokenRef: "a-raw-token" })), { code: "integration_configuration_invalid" });
95
+ });
96
+
97
+ test("the Env refresh token seeds the shared connection, which then refreshes on its own", async () => {
98
+ const { service, store, refreshes, call, advance } = setup();
99
+ assert.deepEqual(await service.status({ context: owner, integrationId }), {
100
+ status: "connected", source: "env", provider: "google-calendar", integrationId, grantedScopes: [listScope],
101
+ callbackUrl: "http://127.0.0.1:8080/callback"
102
+ });
103
+
104
+ await call();
105
+ assert.deepEqual(refreshes, ["seed-token-1"]);
106
+ const stored = store.connections.get(integrationId);
107
+ assert.equal(stored.source, "env");
108
+ assert.equal(stored.tokens.refreshToken, "rotated-1");
109
+ assert.equal(JSON.stringify(stored).includes("seed-token-1"), false, "the seed itself is not kept once rotated");
110
+ assert.equal((await service.status({ context: owner, integrationId })).source, "env");
111
+
112
+ await call();
113
+ assert.deepEqual(refreshes, ["seed-token-1"], "a valid access token is reused");
114
+ advance(3600 * 1000);
115
+ await call();
116
+ assert.deepEqual(refreshes, ["seed-token-1", "rotated-1"], "the rotated token renews, not the seed");
117
+ });
118
+
119
+ test("a replaced Env value reseeds on the next call", async () => {
120
+ const { variables, refreshes, call } = setup();
121
+ await call();
122
+ variables.GOOGLE_REFRESH_TOKEN = "seed-token-2";
123
+ await call();
124
+ assert.deepEqual(refreshes, ["seed-token-1", "seed-token-2"]);
125
+ });
126
+
127
+ test("a revoked Env token asks for a new value, and a new value recovers", async () => {
128
+ const { service, variables, revoked, call } = setup();
129
+ revoked.add("seed-token-1");
130
+ await assert.rejects(call(), {
131
+ code: "connector_reconnect_required",
132
+ message: "The refresh token in Env no longer works; replace that Env value."
133
+ });
134
+ assert.equal((await service.status({ context: owner, integrationId })).status, "reconnect-required");
135
+ variables.GOOGLE_REFRESH_TOKEN = "seed-token-2";
136
+ assert.equal((await service.status({ context: owner, integrationId })).status, "connected");
137
+ await call();
138
+ });
139
+
140
+ test("an Env-seeded connection is not disconnected or reconnected in the app", async () => {
141
+ const { service, call } = setup();
142
+ await call();
143
+ const envManaged = { code: "connector_env_managed", statusCode: 409, message: "This connection comes from an Env value; unlink or replace that value instead." };
144
+ await assert.rejects(service.disconnect({ context: owner, integrationId }), envManaged);
145
+ await assert.rejects(service.beginAuthorization({ context: owner, integrationId }), envManaged);
146
+ });
147
+
148
+ test("a missing Env value reads as unconfigured", async () => {
149
+ const { service, call } = setup({ env: {} });
150
+ assert.deepEqual(await service.status({ context: owner, integrationId }), { status: "unconfigured", configurationError: "connector_binding_missing" });
151
+ await assert.rejects(call(), { code: "connector_binding_missing" });
152
+ });