@jskit-ai/connectors-core 0.1.60 → 0.1.62

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/connectors-core",
3
- "version": "0.1.60",
3
+ "version": "0.1.62",
4
4
  "description": "Portable integration configuration and server-side account connection runtime.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -53,9 +53,9 @@
53
53
  }
54
54
  },
55
55
  "peerDependencies": {
56
- "@jskit-ai/database-runtime": "0.1.243",
57
- "@jskit-ai/http-runtime": "0.1.241",
58
- "@jskit-ai/kernel": "0.1.243"
56
+ "@jskit-ai/database-runtime": "0.1.245",
57
+ "@jskit-ai/http-runtime": "0.1.243",
58
+ "@jskit-ai/kernel": "0.1.245"
59
59
  },
60
60
  "peerDependenciesMeta": {
61
61
  "@jskit-ai/database-runtime": {
@@ -13,10 +13,13 @@ 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
+
20
23
  function validateCallbackUrl(value) {
21
24
  let url;
22
25
  try { url = new URL(value); } catch { /* reported below */ }
@@ -166,6 +169,38 @@ function createConnectionService({
166
169
  }
167
170
  }
168
171
 
172
+ // An Env refresh token seeds the shared connection; the app then refreshes
173
+ // and stores rotated tokens itself, and reseeds when the value or the OAuth
174
+ // client changes.
175
+ async function envRefreshTokenFor(integration) {
176
+ try {
177
+ const value = await resolveReference(integration.authentication.refreshTokenRef);
178
+ const refreshToken = typeof value === "string" ? value.trim() : "";
179
+ if (!refreshToken || refreshToken === "MISSING" || /\s/u.test(refreshToken)) throw new Error("Invalid token.");
180
+ return { refreshToken, fingerprint: createHash("sha256").update(refreshToken).digest("hex") };
181
+ } catch {
182
+ throw new ConnectorError("connector_binding_missing", "The refresh token binding is missing or invalid.");
183
+ }
184
+ }
185
+
186
+ function envSeedIsCurrent(connection, seed, registration) {
187
+ return connection?.seedFingerprint === seed.fingerprint && connection.clientId === registration.client.client_id &&
188
+ connection.registrationRef === registration.registrationRef &&
189
+ connection.tokenEndpointAuthMethod === registration.tokenEndpointAuthMethod;
190
+ }
191
+
192
+ function envSeededConnection(integrationId, integration, provider, registration, seed) {
193
+ return {
194
+ integrationId, provider: provider.id, registrationRef: registration.registrationRef,
195
+ grantType: registration.grantType, clientId: registration.client.client_id,
196
+ tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod, status: "connected", source: "env",
197
+ seedFingerprint: seed.fingerprint, verifiedAt: now(), settings: structuredClone(integration.settings || {}),
198
+ ...(provider.refreshRequiresRedirectUri ? { callbackUrl: registration.callbackUrl } : {}),
199
+ tokens: { accessToken: null, refreshToken: seed.refreshToken, expiresAt: 0 },
200
+ grantedScopes: [...integration.scopes]
201
+ };
202
+ }
203
+
169
204
  async function serviceAccountGrant(integration, provider, binding, signal) {
170
205
  if (typeof provider.serviceAccountGrant !== "function") {
171
206
  throw new ConnectorError("connector_mode_unavailable", "This provider does not implement service-account authorization.");
@@ -380,6 +415,9 @@ function createConnectionService({
380
415
 
381
416
  async function beginAuthorization({ context, integrationId, verificationInput = {}, signal }) {
382
417
  const { owner, integration, provider } = await access(context, integrationId, "connect");
418
+ if (integration.authentication.refreshTokenRef) {
419
+ throw new ConnectorError("connector_env_managed", ENV_SEEDED_MESSAGE, { statusCode: 409 });
420
+ }
383
421
  // Resource-specific providers validate the first document ID before opening consent.
384
422
  provider.operations[provider.checkOperation].request(verificationInput, integration.settings || {});
385
423
  const registration = await registrationFor(integration, provider);
@@ -544,7 +582,12 @@ function createConnectionService({
544
582
  const usesServiceAccount = integration.authentication.method === "service-account";
545
583
  const usesOAuth = integration.authentication.method === "oauth2";
546
584
  const registration = usesOAuth ? await registrationFor(integration, provider) : null;
585
+ const seed = integration.authentication.refreshTokenRef ? await envRefreshTokenFor(integration) : null;
547
586
  const outcome = await store.withConnection({ owner, integrationId }, async ({ connection, save }) => {
587
+ if (seed && !envSeedIsCurrent(connection, seed, registration)) {
588
+ connection = envSeededConnection(integrationId, integration, provider, registration, seed);
589
+ await save(connection);
590
+ }
548
591
  if (!connection || connection.status !== "connected" || connection.provider !== provider.id ||
549
592
  (connection.method || "oauth2") !== integration.authentication.method ||
550
593
  !isDeepStrictEqual(connection.settings || {}, integration.settings || {}) ||
@@ -597,8 +640,11 @@ function createConnectionService({
597
640
  });
598
641
  return { result };
599
642
  } catch (error) {
600
- const failure = providerError(error);
601
- if (failure.code === "connector_reconnect_required") await save({ ...connection, status: "reconnect-required" });
643
+ let failure = providerError(error);
644
+ if (failure.code === "connector_reconnect_required") {
645
+ await save({ ...connection, status: "reconnect-required" });
646
+ if (seed) failure = new ConnectorError(failure.code, "The refresh token in Env no longer works; replace that Env value.", { statusCode: 401 });
647
+ }
602
648
  // Commit rotated tokens and reconnect state even when the API request fails.
603
649
  return { failure };
604
650
  }
@@ -611,7 +657,7 @@ function createConnectionService({
611
657
  const { owner, integration, provider } = await access(context, integrationId, "status");
612
658
  const registration = config.registrations[integration.authentication.registrationRef];
613
659
  return store.withConnection({ owner, integrationId }, async ({ connection }) => {
614
- if (connection && (connection.provider !== integration.provider ||
660
+ if (connection && !integration.authentication.refreshTokenRef && (connection.provider !== integration.provider ||
615
661
  (connection.method || "oauth2") !== integration.authentication.method ||
616
662
  !isDeepStrictEqual(connection.settings || {}, integration.settings || {}) ||
617
663
  (["api-key", "service-account"].includes(integration.authentication.method) && connection.secretRef !== integration.authentication.secretRef) ||
@@ -627,6 +673,13 @@ function createConnectionService({
627
673
  if (integration.authentication.method === "oauth2") {
628
674
  const resolved = await registrationFor(integration, provider);
629
675
  callbackUrl = resolved.callbackUrl;
676
+ if (integration.authentication.refreshTokenRef) {
677
+ const seed = await envRefreshTokenFor(integration);
678
+ // A new value is used on the next call; until then it reads as connected from Env.
679
+ if (!envSeedIsCurrent(connection, seed, resolved)) {
680
+ return { status: "connected", source: "env", provider: provider.id, integrationId, grantedScopes: [...integration.scopes], callbackUrl };
681
+ }
682
+ }
630
683
  if (connection && provider.refreshRequiresRedirectUri && resolved.grantType === "authorization_code" &&
631
684
  connection.callbackUrl !== resolved.callbackUrl) {
632
685
  return publicConnection({ ...connection, status: "reconnect-required" });
@@ -647,7 +700,10 @@ function createConnectionService({
647
700
  }
648
701
 
649
702
  async function disconnect({ context, integrationId }) {
650
- const { owner } = await access(context, integrationId, "disconnect");
703
+ const { owner, integration } = await access(context, integrationId, "disconnect");
704
+ if (integration.authentication.refreshTokenRef) {
705
+ throw new ConnectorError("connector_env_managed", ENV_SEEDED_MESSAGE, { statusCode: 409 });
706
+ }
651
707
  await store.withConnection({ owner, integrationId }, async ({ remove }) => remove());
652
708
  return { status: "disconnected" };
653
709
  }
@@ -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({
@@ -117,11 +118,19 @@ function validateIntegrationConfiguration(input, { providers = [], allowUnknownP
117
118
  const provider = definitions.get(integration.provider);
118
119
  const registration = config.registrations[auth.registrationRef];
119
120
  const grantType = registration?.grantType || "authorization_code";
121
+ if (auth.refreshTokenRef && auth.method !== "oauth2") {
122
+ fieldErrors[`${path}.authentication.refreshTokenRef`] = "Only an OAuth connection can start from a refresh token.";
123
+ }
120
124
  if (auth.method === "oauth2") {
121
125
  if (!auth.registrationRef || !Object.hasOwn(config.registrations, auth.registrationRef)) {
122
126
  fieldErrors[`${path}.authentication.registrationRef`] = "Select a registration defined in this file.";
123
127
  }
124
128
  if (auth.secretRef) fieldErrors[`${path}.authentication.secretRef`] = "Use the registration's secret reference.";
129
+ if (auth.refreshTokenRef && integration.accountMode !== "shared") {
130
+ fieldErrors[`${path}.authentication.refreshTokenRef`] = "Only a shared connection can start from a refresh token in Env.";
131
+ } else if (auth.refreshTokenRef && grantType !== "authorization_code") {
132
+ fieldErrors[`${path}.authentication.refreshTokenRef`] = "Client credentials do not use a refresh token.";
133
+ }
125
134
  if (grantType === "client_credentials" && integration.accountMode === "per-user") {
126
135
  fieldErrors[`${path}.accountMode`] = "A service account cannot connect as each app user.";
127
136
  }
@@ -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
+ });