@jskit-ai/connectors-core 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ configuration validation.
15
15
 
16
16
  See [application-owned OAuth callbacks](docs/oauth-callbacks.md) for callback
17
17
  routes, environment bindings and changes of domain or hosting.
18
- The [application setup guide](docs/online-setup.md) explains credential and
18
+ The [application setup guide](docs/application-setup.md) explains credential and
19
19
  runtime ownership for hosted editors and direct CLI use.
20
20
 
21
21
  ## Pre-release migration to application-owned connections
@@ -136,6 +136,11 @@ retain placeholders while an administrator finishes setup.
136
136
  Never trust owner IDs supplied by a browser. Include deployment/environment
137
137
  identity in `applicationId` when several environments share storage.
138
138
 
139
+ `@jskit-ai/connectors-core/server/types` names these contracts for code that
140
+ composes the service: `ConnectorAuthorize`, `ResolveReference`,
141
+ `ConnectionStore`, `ConnectorProvider`, `ConnectionServiceOptions` and the
142
+ `ConnectorsCore` service itself.
143
+
139
144
  For `invoke`, `input` is a separate copy of the caller's operation input. It is
140
145
  omitted for connection-management methods. Check it when permission depends on
141
146
  the requested tool, document or action. The service snapshots input before
@@ -4,35 +4,34 @@ An application can expose its connection setup to a CLI or an editor using the
4
4
  same `createConnectionService()` instance as its backend. JSKIT owns connection
5
5
  logic and optional stores; the application owns the executable, operator
6
6
  identity, environment, callback route and deployment. Neither the backend nor
7
- this library requires Vibe64 to run.
7
+ this library requires a particular host to run.
8
8
 
9
- For Vibe64, the application declares its own command in its Stack:
10
-
11
- ```markdown
12
- ## Integration setup
13
-
14
- - Command with `nodejs` in `.`: `node` `scripts/integrations.js`
15
- ```
9
+ The host that runs the command (an editor, a CLI wrapper or an operations tool)
10
+ says how the application declares it, for example as `node
11
+ scripts/integrations.js`, and owns its request protocol: the protocol id that
12
+ requests carry and responses echo, the input and output size bounds and the
13
+ time limit.
16
14
 
17
15
  The script loads the application's existing configuration and server composition.
18
16
  Resolve administrator references from its existing Env using
19
- `createEnvironmentReferenceResolver()`. Use an application-owned store whose
17
+ `createEnvironmentReferenceResolver(process.env)`. Use an application-owned store whose
20
18
  identity includes the environment. Keep runtime data outside source and release
21
19
  snapshots. The script's operator context must be established by the application;
22
20
  never accept applicationId, subjectId or administrator privileges from stdin.
23
21
  The normal service `authorize` callback still enforces connect/status/disconnect
24
- permissions. An editor user's identity is not an application user's identity.
22
+ permissions. A host user's identity is not an application user's identity.
25
23
 
26
24
  ## Dispatch example
27
25
 
28
26
  The following function belongs in the application's script. It accepts the
29
- already-created service, validated configuration and trusted operator context.
30
- It is deliberately a composition example, not a new JSKIT command runner.
27
+ already-created service, validated configuration, trusted operator context and
28
+ the protocol id of the host that runs the command. It is deliberately a
29
+ composition example, not a new JSKIT command runner.
31
30
 
32
31
  ```js
33
- async function dispatchSetup(request, { connections, configuration, context }) {
34
- const { protocol, requestId, operation, integrationId } = request;
35
- if (protocol !== "vibe64.integration-setup.command.v1" ||
32
+ async function dispatchSetup(request, { protocol, connections, configuration, context }) {
33
+ const { requestId, operation, integrationId } = request;
34
+ if (request.protocol !== protocol ||
36
35
  typeof requestId !== "string" || !requestId ||
37
36
  !["status", "connect", "cancel", "disconnect"].includes(operation) ||
38
37
  !Object.hasOwn(configuration.integrations, integrationId)) {
@@ -94,11 +93,12 @@ async function dispatchSetup(request, { connections, configuration, context }) {
94
93
  }
95
94
  ```
96
95
 
97
- Read exactly one JSON request from stdin with a 32 KiB bound. Write only the
98
- returned JSON object plus a newline to stdout. On failure exit nonzero; do not
99
- print credentials, provider responses or raw exceptions. Close the application's
100
- database pool/resources in `finally`. The editor bounds the command at 30 seconds;
101
- return pending immediately instead of waiting for browser consent.
96
+ Read exactly one JSON request from stdin within the host's input bound. Write
97
+ only the returned JSON object plus a newline to stdout. On failure exit nonzero;
98
+ do not print credentials, provider responses or raw exceptions. Close the
99
+ application's database pool/resources in `finally`. The host bounds the
100
+ command's time; return pending immediately instead of waiting for browser
101
+ consent.
102
102
 
103
103
  `verificationInput` supplies provider-specific check inputs such as a resource
104
104
  ID. It is not a credential entry channel. Keys and client secrets belong in Env.
@@ -107,11 +107,11 @@ A configuration save does not verify credentials or produce a connected state.
107
107
  Status validates required local bindings without calling the provider. Before a
108
108
  first connection, missing credentials or an invalid callback produce
109
109
  `unconfigured` with a safe `configurationError` code; the dispatcher maps that
110
- code to the editor's setup guidance. A previous grant remains available for
110
+ code to the host's setup guidance. A previous grant remains available for
111
111
  disconnect and reports `reconnect-required` when its bindings are incomplete.
112
112
  For a valid authorization-code registration, status and pending authorization
113
113
  report `callbackUrl` resolved from the application's Env. This is the URL to
114
- register with the provider, not the editor's suggestion. Invalid or incomplete
114
+ register with the provider, not one the host suggests. Invalid or incomplete
115
115
  bindings do not expose an unvalidated callback or private Env values.
116
116
 
117
117
  Providers may expose `accountLabel` from their verified account response. Gmail
@@ -127,7 +127,7 @@ the grant; cancelled replacement consent retains the previous grant's label.
127
127
 
128
128
  ### Individual application accounts
129
129
 
130
- For `accountMode: "per-user"`, the editor configures the OAuth client but does
130
+ For `accountMode: "per-user"`, the host configures the OAuth client but does
131
131
  not run this shared-operator dispatcher. The application implements its own
132
132
  connection screen using its existing UI and authenticated routes:
133
133
 
@@ -141,7 +141,7 @@ connection screen using its existing UI and authenticated routes:
141
141
 
142
142
  Supply `{ context, integrationId }` to each operation. Derive `context` from
143
143
  trusted application authentication and membership, never a form field, callback
144
- query or editor identity. The service's `authorize` function must enforce the
144
+ query or host identity. The service's `authorize` function must enforce the
145
145
  application and user boundary. Keep registration credentials in backend Env;
146
146
  each user's grant lives separately in the existing connection store. The browser
147
147
  receives only safe status/consent metadata. OAuth callback state does not replace
@@ -162,7 +162,7 @@ The callback Env value must match both this real route and the provider's
162
162
  registered redirect URI. For a hosted project use its assigned application URL
163
163
  as the starting origin, then append the implemented callback path. Custom domains
164
164
  or explicit overrides require updating that Env value and provider registration;
165
- changing an editor hostname cannot change application identity.
165
+ changing the host's hostname cannot change application identity.
166
166
 
167
167
  Status can recover a still-valid pending attempt after the process restarts.
168
168
  Cancellation consumes its state and preserves an earlier working connection.
package/locales/en.json CHANGED
@@ -27,6 +27,7 @@
27
27
  "schemaVersionUnsupported": "Use numeric schemaVersion 1; other versions require an explicit migration.",
28
28
  "scopeRequired": "Select at least one permission.",
29
29
  "scopeUnsupported": "A selected permission is not supported by this provider configuration.",
30
+ "secretReference": "Use a reference such as env:VARIABLE_NAME.",
30
31
  "serviceAccountPerUser": "A service account cannot connect as each app user.",
31
32
  "serviceAccountRegistration": "Service-account credentials do not use a browser OAuth registration.",
32
33
  "stableName": "Use a stable lowercase name containing letters, digits and hyphens.",
@@ -55,7 +56,6 @@
55
56
  "providerClientIdNotConfigured": "The provider client ID is not configured.",
56
57
  "providerDeniedOperation": "The provider denied this operation.",
57
58
  "providerDoesNotDeclareAssistant": "This provider does not declare this assistant action.",
58
- "providerDoesNotImplementService": "This provider does not implement service-account authorization.",
59
59
  "providerInvalidCredentialPath": "This provider has an invalid credential path.",
60
60
  "providerNonPkceFlowRequires": "This provider's non-PKCE flow requires a confidential client.",
61
61
  "providerOperationInvalidDestination": "The provider operation has an invalid destination.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/connectors-core",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Portable integration configuration and server-side account connection runtime.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -11,6 +11,7 @@
11
11
  "./server": "./src/server/index.js",
12
12
  "./server/storage": "./src/server/storage.js",
13
13
  "./server/file-storage": "./src/server/fileStorage.js",
14
+ "./server/types": "./src/server/types.js",
14
15
  "./locales/en.json": "./locales/en.json"
15
16
  },
16
17
  "dependencies": {
@@ -54,7 +55,7 @@
54
55
  }
55
56
  },
56
57
  "peerDependencies": {
57
- "@jskit-ai/http-runtime": "0.2.3",
58
- "@jskit-ai/kernel": "0.2.3"
58
+ "@jskit-ai/http-runtime": "0.2.4",
59
+ "@jskit-ai/kernel": "0.2.4"
59
60
  }
60
61
  }
@@ -2,6 +2,70 @@ import { defineFeature } from "@jskit-ai/kernel/server/features";
2
2
  import { createSchema } from "json-rest-schema";
3
3
  import { createConnectionService } from "./connectionService.js";
4
4
 
5
+ const integrationId = Object.freeze({ type: "string", required: true, minLength: 1, maxLength: 200 });
6
+ const verificationInput = Object.freeze({ type: "object", additionalProperties: true });
7
+ // Every connectors action serves API and internal callers on app surfaces.
8
+ const connectorsActionReach = Object.freeze({
9
+ channels: Object.freeze(["api", "internal"]),
10
+ surfaces: Object.freeze(["app"])
11
+ });
12
+
13
+ /** @param {{ connections: import("./types.js").ConnectorsCore }} dependencies */
14
+ function createConnectorsActions({ connections }) {
15
+ return Object.freeze([
16
+ Object.freeze({
17
+ id: "connectors.status",
18
+ kind: "query",
19
+ ...connectorsActionReach,
20
+ input: { schema: createSchema({ integrationId }) },
21
+ execute: (input, context) => connections.status({ ...input, context })
22
+ }),
23
+ Object.freeze({
24
+ id: "connectors.connect",
25
+ kind: "command",
26
+ ...connectorsActionReach,
27
+ input: { schema: createSchema({ integrationId, verificationInput }) },
28
+ execute: (input, context) => connections.beginAuthorization({ ...input, context })
29
+ }),
30
+ Object.freeze({
31
+ id: "connectors.verifyClientCredentials",
32
+ kind: "command",
33
+ ...connectorsActionReach,
34
+ input: { schema: createSchema({ integrationId, verificationInput }) },
35
+ execute: (input, context) => connections.connectClientCredentials({ ...input, context })
36
+ }),
37
+ Object.freeze({
38
+ id: "connectors.verifyServiceAccount",
39
+ kind: "command",
40
+ ...connectorsActionReach,
41
+ input: { schema: createSchema({ integrationId, verificationInput }) },
42
+ execute: (input, context) => connections.connectServiceAccount({ ...input, context })
43
+ }),
44
+ Object.freeze({
45
+ id: "connectors.verifyApiKey",
46
+ kind: "command",
47
+ ...connectorsActionReach,
48
+ input: { schema: createSchema({ integrationId, verificationInput }) },
49
+ execute: (input, context) => connections.connectApiKey({ ...input, context })
50
+ }),
51
+ Object.freeze({
52
+ id: "connectors.verifyWithoutCredentials",
53
+ kind: "command",
54
+ ...connectorsActionReach,
55
+ input: { schema: createSchema({ integrationId, verificationInput }) },
56
+ execute: (input, context) => connections.connectWithoutCredentials({ ...input, context })
57
+ }),
58
+ Object.freeze({
59
+ id: "connectors.disconnect",
60
+ kind: "command",
61
+ ...connectorsActionReach,
62
+ input: { schema: createSchema({ integrationId }) },
63
+ execute: (input, context) => connections.disconnect({ ...input, context })
64
+ })
65
+ ]);
66
+ }
67
+
68
+ /** @param {import("./types.js").ConnectionServiceOptions} options */
5
69
  function createConnectorsFeature(options) {
6
70
  return defineFeature({
7
71
  id: "connectors.core",
@@ -11,24 +75,7 @@ function createConnectorsFeature(options) {
11
75
  return { connections: createConnectionService(options) };
12
76
  },
13
77
  actions({ connections }) {
14
- const integrationId = { type: "string", required: true, minLength: 1, maxLength: 200 };
15
- return [
16
- ["status", "query", { integrationId }, (input, context) => connections.status({ ...input, context })],
17
- ["connect", "command", { integrationId, verificationInput: { type: "object", additionalProperties: true } }, (input, context) => connections.beginAuthorization({ ...input, context })],
18
- ["verifyClientCredentials", "command", { integrationId, verificationInput: { type: "object", additionalProperties: true } }, (input, context) => connections.connectClientCredentials({ ...input, context })],
19
- ["verifyServiceAccount", "command", { integrationId, verificationInput: { type: "object", additionalProperties: true } }, (input, context) => connections.connectServiceAccount({ ...input, context })],
20
- ["verifyApiKey", "command", { integrationId, verificationInput: { type: "object", additionalProperties: true } }, (input, context) => connections.connectApiKey({ ...input, context })],
21
- ["verifyWithoutCredentials", "command", { integrationId, verificationInput: { type: "object", additionalProperties: true } }, (input, context) => connections.connectWithoutCredentials({ ...input, context })],
22
- ["disconnect", "command", { integrationId }, (input, context) => connections.disconnect({ ...input, context })]
23
- ].map(([name, kind, fields, execute]) => ({
24
- id: `connectors.${name}`,
25
- kind,
26
- channels: ["api", "internal"],
27
- surfaces: ["app"],
28
- idempotency: "none",
29
- input: { schema: createSchema(fields) },
30
- execute
31
- }));
78
+ return createConnectorsActions({ connections });
32
79
  }
33
80
  });
34
81
  }
@@ -0,0 +1,129 @@
1
+ import * as oauth from "oauth4webapi";
2
+ import { createHash } from "node:crypto";
3
+ import { registrationClientAuthentication, registrationGrantType } from "../shared/configuration.js";
4
+ import { ConnectorError } from "./errors.js";
5
+
6
+ function validateCallbackUrl(value) {
7
+ let url;
8
+ try { url = new URL(value); } catch { /* reported below */ }
9
+ const localHttp = url?.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
10
+ if (!url || (!localHttp && url.protocol !== "https:") || url.username || url.password || url.hash || url.search) {
11
+ throw new ConnectorError("connector_callback_invalid", "Configure an HTTPS callback URL, or a local development callback.", { messageCode: "connectors.connection.configureHttpsCallbackUrlLocal" });
12
+ }
13
+ return url.href;
14
+ }
15
+
16
+ /**
17
+ * The credentials an integration's bindings resolve to: its OAuth
18
+ * registration, API key, service-account credential or Env refresh token.
19
+ * @param {object} dependencies
20
+ * @param {Record<string, any>} dependencies.config The validated integration configuration.
21
+ * @param {import("./types.js").ResolveReference} dependencies.resolveReference
22
+ */
23
+ function createConnectionCredentials({ config, resolveReference }) {
24
+ /**
25
+ * @param {Record<string, any>} integration
26
+ * @param {import("./providerRequests.js").ConnectionProvider} provider
27
+ */
28
+ async function registrationFor(integration, provider) {
29
+ const registration = config.registrations[integration.authentication.registrationRef];
30
+ if (registration?.source !== "own" || integration.authentication.method !== "oauth2" || !provider.oauth) {
31
+ throw new ConnectorError("connector_mode_unavailable", "This runtime does not yet support the selected credential mode.", { messageCode: "connectors.connection.runtimeDoesNotYetSupport" });
32
+ }
33
+ if (registration.clientId.trim() === "MISSING") {
34
+ throw new ConnectorError("connector_binding_missing", "The provider client ID is not configured.", { messageCode: "connectors.connection.providerClientIdNotConfigured" });
35
+ }
36
+ const grantType = registrationGrantType(registration);
37
+ const tokenEndpointAuthMethod = registrationClientAuthentication(registration);
38
+ if (provider.oauthPkce === false && tokenEndpointAuthMethod === "none") {
39
+ throw new ConnectorError("connector_mode_unavailable", "This provider's non-PKCE flow requires a confidential client.", { messageCode: "connectors.connection.providerNonPkceFlowRequires" });
40
+ }
41
+ let clientSecret;
42
+ let callback;
43
+ try {
44
+ if (tokenEndpointAuthMethod !== "none") {
45
+ clientSecret = await resolveReference(registration.clientSecretRef);
46
+ if (typeof clientSecret !== "string" || !clientSecret.trim() || clientSecret.trim() === "MISSING") throw new Error("Invalid client secret.");
47
+ }
48
+ if (grantType === "authorization_code") callback = await resolveReference(registration.callbackUrlRef);
49
+ } catch {
50
+ throw new ConnectorError("connector_binding_missing", "A required registration binding is missing.", { messageCode: "connectors.connection.requiredRegistrationBindingMissing" });
51
+ }
52
+ const callbackUrl = grantType === "authorization_code" ? validateCallbackUrl(callback) : undefined;
53
+ // readable-check: allow method-probe - optional operation: validateCallbackUrl is an optional hook of the provider definition
54
+ if (callbackUrl && typeof provider.validateCallbackUrl === "function" && provider.validateCallbackUrl(callbackUrl) !== true) {
55
+ throw new ConnectorError("connector_callback_invalid", "The callback URL does not meet this provider's requirements.", { messageCode: "connectors.connection.callbackUrlDoesNotMeet" });
56
+ }
57
+ let clientAuth = tokenEndpointAuthMethod === "none" ? oauth.None()
58
+ : tokenEndpointAuthMethod === "client_secret_basic" ? oauth.ClientSecretBasic(clientSecret) : oauth.ClientSecretPost(clientSecret);
59
+ if (tokenEndpointAuthMethod === "client_secret_basic" && provider.oauthBasicEncoding === "raw") {
60
+ clientAuth = (_as, client, _body, headers) => {
61
+ headers.set("authorization", `Basic ${Buffer.from(`${client.client_id}:${clientSecret}`).toString("base64")}`);
62
+ };
63
+ }
64
+ return {
65
+ // readable-check: allow method-probe - data: a provider definition gives oauth as metadata or as a function of the settings
66
+ oauth: typeof provider.oauth === "function" ? provider.oauth(integration.settings) : provider.oauth,
67
+ // readable-check: allow method-probe - data: a provider definition gives oauthResource as a value or as a function of the settings
68
+ resource: typeof provider.oauthResource === "function" ? provider.oauthResource(integration.settings) : provider.oauthResource,
69
+ client: { client_id: registration.clientId },
70
+ clientAuth: provider.oauthClientIdParameter ? (as, client, body, headers) => {
71
+ clientAuth(as, client, body, headers);
72
+ body.set(provider.oauthClientIdParameter, client.client_id);
73
+ body.delete("client_id");
74
+ } : clientAuth,
75
+ tokenEndpointAuthMethod,
76
+ grantType,
77
+ callbackUrl,
78
+ registrationRef: integration.authentication.registrationRef
79
+ };
80
+ }
81
+
82
+ /**
83
+ * @param {Record<string, any>} integration
84
+ * @param {import("./providerRequests.js").ConnectionProvider} provider
85
+ */
86
+ async function apiKeyFor(integration, provider) {
87
+ if (!integration.authentication.secretRef && provider.apiKeySecretOptional) return "";
88
+ try {
89
+ const value = await resolveReference(integration.authentication.secretRef);
90
+ if (typeof value !== "string" || (!provider.apiKeySecretOptional && !value.trim()) || value.trim() === "MISSING" || /[\r\n]/u.test(value)) throw new Error("Invalid key.");
91
+ return value;
92
+ } catch {
93
+ throw new ConnectorError("connector_binding_missing", "The API key binding is missing or invalid.", { messageCode: "connectors.connection.apiKeyBindingMissingInvalid" });
94
+ }
95
+ }
96
+
97
+ async function serviceAccountCredentialFor(integration) {
98
+ try {
99
+ const credential = await resolveReference(integration.authentication.secretRef);
100
+ if (typeof credential !== "string" || !credential.trim() || credential.trim() === "MISSING" || credential.length > 65_536 || credential.includes("\0")) throw new Error("Invalid credential.");
101
+ return { credential, fingerprint: createHash("sha256").update(credential).digest("hex") };
102
+ } catch {
103
+ throw new ConnectorError("connector_binding_missing", "The service-account credential binding is missing or invalid.", { messageCode: "connectors.connection.serviceAccountCredentialBindingMissing" });
104
+ }
105
+ }
106
+
107
+ // An Env refresh token seeds the shared connection; the app then refreshes
108
+ // and stores rotated tokens itself, and reseeds when the value or the OAuth
109
+ // client changes.
110
+ async function envRefreshTokenFor(integration) {
111
+ try {
112
+ const value = await resolveReference(integration.authentication.refreshTokenRef);
113
+ const refreshToken = typeof value === "string" ? value.trim() : "";
114
+ if (!refreshToken || refreshToken === "MISSING" || /\s/u.test(refreshToken)) throw new Error("Invalid token.");
115
+ return { refreshToken, fingerprint: createHash("sha256").update(refreshToken).digest("hex") };
116
+ } catch {
117
+ throw new ConnectorError("connector_binding_missing", "The refresh token binding is missing or invalid.", { messageCode: "connectors.connection.refreshTokenBindingMissingInvalid" });
118
+ }
119
+ }
120
+
121
+ return Object.freeze({
122
+ registrationFor,
123
+ apiKeyFor,
124
+ serviceAccountCredentialFor,
125
+ envRefreshTokenFor
126
+ });
127
+ }
128
+
129
+ export { createConnectionCredentials };