@vercel/connect 2.5.7-snapshot-c4f338842961 → 2.6.0
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 +8 -2
- package/dist/eve/connection-authorization.d.ts +21 -11
- package/dist/eve/connection-authorization.js +61 -15
- package/dist/eve/index.d.ts +1 -0
- package/dist/eve/index.js +1 -0
- package/dist/eve/provision-oauth-connector.d.ts +80 -6
- package/dist/eve/provision-oauth-connector.js +263 -42
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -237,8 +237,14 @@ export default defineMcpClientConnection({
|
|
|
237
237
|
By default, `connect()` only uses connectors already linked to the project and
|
|
238
238
|
does not provision or modify connectors at runtime. Pass `autoProvision: true`
|
|
239
239
|
to opt in. When enabled, `connect()` first tries the token or authorization
|
|
240
|
-
request; if the connector is missing or not linked, it
|
|
241
|
-
connector
|
|
240
|
+
request; if the connector is missing or not linked, it asks Vercel Connect to
|
|
241
|
+
resolve the connector by its project-local UID (`'linear'` above) and retries
|
|
242
|
+
the request once with the returned connector id. Vercel Connect reuses a
|
|
243
|
+
connector already mapped to that UID or linked to the project, or creates one
|
|
244
|
+
linked to the deploying project. It never widens an existing link: when setup
|
|
245
|
+
is required (for example, enabling the connector for another environment), the
|
|
246
|
+
call fails with a non-retryable `connector_setup_required` authorization error.
|
|
247
|
+
Teams without project-local UIDs provision the connector by team UID instead.
|
|
242
248
|
|
|
243
249
|
### Better Auth
|
|
244
250
|
|
|
@@ -58,9 +58,10 @@ export type EveConnectionAuthorizationContext = Parameters<NonInteractiveAuthori
|
|
|
58
58
|
export interface EveAuthorizationOptions {
|
|
59
59
|
/**
|
|
60
60
|
* Vercel Connect OAuth connector identifier. Accepts either the
|
|
61
|
-
* opaque service connector key (`scl_...`) or
|
|
62
|
-
*
|
|
63
|
-
* connector
|
|
61
|
+
* opaque service connector key (`scl_...`) or a human-readable UID.
|
|
62
|
+
* When the team has project-local UIDs enabled, a UID is resolved first
|
|
63
|
+
* as the project-local UID of a connector linked to the deploying
|
|
64
|
+
* project, then as the connector's team UID.
|
|
64
65
|
*/
|
|
65
66
|
readonly connector: string;
|
|
66
67
|
/**
|
|
@@ -130,14 +131,23 @@ export interface EveAuthorizationOptions {
|
|
|
130
131
|
* opt in to runtime provisioning.
|
|
131
132
|
*
|
|
132
133
|
* The provision request is authenticated with the deployment OIDC token
|
|
133
|
-
* and carries the eve connection's `url` plus this connector UID
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
134
|
+
* and carries the eve connection's `url` plus this connector UID as the
|
|
135
|
+
* project-local UID (`projectUid`). Connect returns the connector already
|
|
136
|
+
* mapped to that UID in the deploying project, reuses a compatible
|
|
137
|
+
* connector already linked to the project and environment, or creates a
|
|
138
|
+
* new connector linked to the project under that UID. Existing links are
|
|
139
|
+
* never widened; cases that need user-authorized setup fail with a
|
|
140
|
+
* non-retryable `connector_setup_required` authorization error. Teams
|
|
141
|
+
* without project-local UIDs fall back to provisioning the connector by
|
|
142
|
+
* team UID.
|
|
137
143
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
144
|
+
* For a few minutes after provisioning, token, authorization, revocation,
|
|
145
|
+
* and eviction calls address the connector by the id Connect returned,
|
|
146
|
+
* because a newly created connector may not resolve by UID until Connect's
|
|
147
|
+
* indexes catch up; afterwards they use the UID again. Existing linked
|
|
148
|
+
* connectors are used directly without a provisioning request. Opaque
|
|
149
|
+
* connector ids (`scl_...`) and connections without a URL are skipped
|
|
150
|
+
* automatically.
|
|
141
151
|
*/
|
|
142
152
|
readonly autoProvision?: boolean;
|
|
143
153
|
/**
|
|
@@ -224,7 +234,7 @@ export interface Experimental_EveEventsBackend {
|
|
|
224
234
|
* Token-fetching identity continues to be driven by the
|
|
225
235
|
* `getToken` / `startAuthorization` / `completeAuthorization`
|
|
226
236
|
* callbacks. When auto-provisioning is enabled, the same connector value is
|
|
227
|
-
* also sent as the managed OAuth UID.
|
|
237
|
+
* also sent as the managed OAuth project-local UID.
|
|
228
238
|
*/
|
|
229
239
|
export interface VercelConnectMetadata {
|
|
230
240
|
/** Live capability; compilers must omit this from serializable metadata. */
|
|
@@ -33,7 +33,7 @@ import { startAuthorization } from '../authorization.js';
|
|
|
33
33
|
import { experimental_connectEventsAdapter, } from '../ai-sdk/connect-events-adapter.js';
|
|
34
34
|
import { verifyMcpEvent } from '../mcp/verify-event.js';
|
|
35
35
|
import { ConnectError, ConnectorInstallationRequiredError, deleteTokenCacheEntry, getTokenResponse, NoValidTokenError, revokeToken, UserAuthorizationRequiredError, } from '../token.js';
|
|
36
|
-
import { provisionEveOAuthConnector } from './provision-oauth-connector.js';
|
|
36
|
+
import { ConnectorProvisioningInProgressError, ConnectorSetupRequiredError, findProvisionedConnector, forgetProvisionedConnector, getProvisionedConnector, getProvisionedConnectorIds, provisionEveOAuthConnector, resolveProvisionScope, } from './provision-oauth-connector.js';
|
|
37
37
|
export function connect(input) {
|
|
38
38
|
const options = normalizeAuthorizationOptions(input);
|
|
39
39
|
const requirement = options.service === undefined
|
|
@@ -83,7 +83,7 @@ function makeEvict(options) {
|
|
|
83
83
|
// token included) and clears the in-process cache. Best-effort —
|
|
84
84
|
// a failed or duplicate revoke must not mask the auth error that
|
|
85
85
|
// triggered eviction.
|
|
86
|
-
await revokeToken(options
|
|
86
|
+
await revokeToken(resolveRevocationConnector(options, connection), {
|
|
87
87
|
subject: params.subject,
|
|
88
88
|
installationId: params.installationId,
|
|
89
89
|
}, options.connectOptions);
|
|
@@ -94,9 +94,33 @@ function makeEvict(options) {
|
|
|
94
94
|
// gone even when the server-side revoke failed.
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
+
// Tokens fetched shortly after provisioning are cached under the
|
|
98
|
+
// provisioned connector id, possibly by another `connect()` definition
|
|
99
|
+
// for the same connector, so drop those entries too.
|
|
97
100
|
deleteTokenCacheEntry(options.connector, params);
|
|
101
|
+
for (const id of getProvisionedConnectorIds(options.connector)) {
|
|
102
|
+
deleteTokenCacheEntry(id, params);
|
|
103
|
+
}
|
|
98
104
|
};
|
|
99
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Picks the connector reference revocation should address, using the same
|
|
108
|
+
* policy as token retrieval: only `autoProvision` definitions address a
|
|
109
|
+
* recently provisioned id, so an opted-out definition never revokes a grant
|
|
110
|
+
* on a connector another definition provisioned. Without a connection, the
|
|
111
|
+
* id is looked up across server URLs and used only when unambiguous.
|
|
112
|
+
*/
|
|
113
|
+
function resolveRevocationConnector(options, connection) {
|
|
114
|
+
if (options.autoProvision !== true) {
|
|
115
|
+
return options.connector;
|
|
116
|
+
}
|
|
117
|
+
if (connection === undefined) {
|
|
118
|
+
return (findProvisionedConnector(options.connector, options.connectOptions) ??
|
|
119
|
+
options.connector);
|
|
120
|
+
}
|
|
121
|
+
const scope = resolveProvisionScope(options.connector, connection, options.connectOptions);
|
|
122
|
+
return (scope && getProvisionedConnector(scope)) ?? options.connector;
|
|
123
|
+
}
|
|
100
124
|
function buildEventsBackend(options) {
|
|
101
125
|
return {
|
|
102
126
|
verify: verifyMcpEvent,
|
|
@@ -138,7 +162,7 @@ function buildInteractiveDefinition(options) {
|
|
|
138
162
|
async getToken({ principal, connection, }) {
|
|
139
163
|
try {
|
|
140
164
|
const params = await buildTokenParams(options, principal, connection);
|
|
141
|
-
const response = await withConnectorProvisioningFallback(options, connection,
|
|
165
|
+
const response = await withConnectorProvisioningFallback(options, connection, connector => getTokenResponse(connector, params, getTokenConnectOptions(options)));
|
|
142
166
|
return { token: response.token, expiresAt: response.expiresAt };
|
|
143
167
|
}
|
|
144
168
|
catch (error) {
|
|
@@ -167,7 +191,7 @@ function buildInteractiveDefinition(options) {
|
|
|
167
191
|
// work without an OAuth-style redirect-URI allowlist.
|
|
168
192
|
const completionWebhook = connectCompletionWebhook(webhook);
|
|
169
193
|
const params = await buildTokenParams(options, principal, connection);
|
|
170
|
-
const response = await withConnectorProvisioningFallback(options, connection,
|
|
194
|
+
const response = await withConnectorProvisioningFallback(options, connection, connector => startAuthorization(connector, params, {
|
|
171
195
|
...options.connectOptions,
|
|
172
196
|
callbackUrl: callbackUrl ?? webhook,
|
|
173
197
|
...(completionWebhook ? { webhook: completionWebhook } : null),
|
|
@@ -202,7 +226,7 @@ function buildInteractiveDefinition(options) {
|
|
|
202
226
|
async completeAuthorization({ principal, connection, }) {
|
|
203
227
|
try {
|
|
204
228
|
const params = await buildTokenParams(options, principal, connection);
|
|
205
|
-
const response = await withConnectorProvisioningFallback(options, connection,
|
|
229
|
+
const response = await withConnectorProvisioningFallback(options, connection, connector => getTokenResponse(connector, params, options.connectOptions));
|
|
206
230
|
return { token: response.token, expiresAt: response.expiresAt };
|
|
207
231
|
}
|
|
208
232
|
catch (error) {
|
|
@@ -228,7 +252,7 @@ function buildNonInteractiveDefinition(options) {
|
|
|
228
252
|
async getToken({ principal, connection, }) {
|
|
229
253
|
try {
|
|
230
254
|
const params = await buildTokenParams(options, principal, connection);
|
|
231
|
-
const response = await withConnectorProvisioningFallback(options, connection,
|
|
255
|
+
const response = await withConnectorProvisioningFallback(options, connection, connector => getTokenResponse(connector, params, getTokenConnectOptions(options)));
|
|
232
256
|
return { token: response.token, expiresAt: response.expiresAt };
|
|
233
257
|
}
|
|
234
258
|
catch (error) {
|
|
@@ -238,21 +262,29 @@ function buildNonInteractiveDefinition(options) {
|
|
|
238
262
|
};
|
|
239
263
|
}
|
|
240
264
|
async function withConnectorProvisioningFallback(options, connection, operation) {
|
|
265
|
+
const scope = options.autoProvision === true
|
|
266
|
+
? resolveProvisionScope(options.connector, connection, options.connectOptions)
|
|
267
|
+
: undefined;
|
|
268
|
+
if (scope === undefined) {
|
|
269
|
+
return operation(options.connector);
|
|
270
|
+
}
|
|
271
|
+
const attempted = getProvisionedConnector(scope) ?? options.connector;
|
|
241
272
|
try {
|
|
242
|
-
return await operation();
|
|
273
|
+
return await operation(attempted);
|
|
243
274
|
}
|
|
244
275
|
catch (error) {
|
|
245
|
-
if (
|
|
246
|
-
!isMissingConnectorOrProjectLink(error)) {
|
|
276
|
+
if (!isMissingConnectorOrProjectLink(error)) {
|
|
247
277
|
throw error;
|
|
248
278
|
}
|
|
249
279
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
280
|
+
forgetProvisionedConnector(scope, attempted);
|
|
281
|
+
// A concurrent caller may already have provisioned a replacement.
|
|
282
|
+
const current = getProvisionedConnector(scope);
|
|
283
|
+
if (current !== undefined && current !== attempted) {
|
|
284
|
+
return operation(current);
|
|
285
|
+
}
|
|
286
|
+
const provisioned = await provisionEveOAuthConnector(scope, options.connectOptions);
|
|
287
|
+
return operation(provisioned ?? options.connector);
|
|
256
288
|
}
|
|
257
289
|
function isMissingConnectorOrProjectLink(error) {
|
|
258
290
|
if (!(error instanceof ConnectError)) {
|
|
@@ -354,6 +386,20 @@ function translate(error, phase, options) {
|
|
|
354
386
|
message: error.message,
|
|
355
387
|
});
|
|
356
388
|
}
|
|
389
|
+
if (error instanceof ConnectorSetupRequiredError) {
|
|
390
|
+
return new ConnectionAuthorizationFailedError('connect', {
|
|
391
|
+
message: error.message,
|
|
392
|
+
reason: 'connector_setup_required',
|
|
393
|
+
retryable: false,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
if (error instanceof ConnectorProvisioningInProgressError) {
|
|
397
|
+
return new ConnectionAuthorizationFailedError('connect', {
|
|
398
|
+
message: error.message,
|
|
399
|
+
reason: 'connector_provisioning_in_progress',
|
|
400
|
+
retryable: true,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
357
403
|
// `ConnectorInstallationRequiredError` is terminal in both modes:
|
|
358
404
|
// the operator has not installed the Vercel Connect connector for
|
|
359
405
|
// this team.
|
package/dist/eve/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* everything from `@vercel/connect/eve`.
|
|
8
8
|
*/
|
|
9
9
|
export { connect, type EveAuthorizationInput, type EveAuthorizationOptions, type EveConnectAuthorizationDefinition, type EveConnectionAuthorizationContext, type ConnectAuthorizationPhase, type VercelConnectMetadata, type Experimental_EveEventsBackend, } from './connection-authorization.js';
|
|
10
|
+
export { ConnectorProvisioningInProgressError, ConnectorSetupRequiredError, } from './provision-oauth-connector.js';
|
|
10
11
|
export { CONNECT_OAUTH_ISSUER, connectOAuth, type ConnectOAuthAudienceEnvironment, type ConnectOAuthEnvironment, type ConnectOAuthOptions, } from './connect-oauth.js';
|
|
11
12
|
export { connectDiscordCredentials, type ConnectDiscordCredentialsParams, } from './discord-credentials.js';
|
|
12
13
|
export { connectGitHubCredentials, type ConnectGitHubCredentialsParams, } from './github-credentials.js';
|
package/dist/eve/index.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* everything from `@vercel/connect/eve`.
|
|
8
8
|
*/
|
|
9
9
|
export { connect, } from './connection-authorization.js';
|
|
10
|
+
export { ConnectorProvisioningInProgressError, ConnectorSetupRequiredError, } from './provision-oauth-connector.js';
|
|
10
11
|
export { CONNECT_OAUTH_ISSUER, connectOAuth, } from './connect-oauth.js';
|
|
11
12
|
export { connectDiscordCredentials, } from './discord-credentials.js';
|
|
12
13
|
export { connectGitHubCredentials, } from './github-credentials.js';
|
|
@@ -1,9 +1,83 @@
|
|
|
1
|
-
import type { ConnectOptions } from '../token.js';
|
|
1
|
+
import type { ConnectErrorOptions, ConnectOptions } from '../token.js';
|
|
2
|
+
import { ConnectError } from '../token.js';
|
|
2
3
|
import type { EveConnectionAuthorizationContext } from './connection-authorization.js';
|
|
3
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Vercel Connect could not resolve the connection's project UID to a
|
|
6
|
+
* connector without user-authorized setup, such as enabling the connector
|
|
7
|
+
* for this environment or choosing between ambiguous connectors. Retrying
|
|
8
|
+
* the request does not help until the setup is done.
|
|
9
|
+
*/
|
|
10
|
+
export declare class ConnectorSetupRequiredError extends ConnectError {
|
|
11
|
+
/** Machine-readable cause reported by Vercel Connect. */
|
|
12
|
+
readonly reason?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Connector the setup applies to, when Vercel Connect identifies one,
|
|
15
|
+
* such as a connector that was removed from the project and can be
|
|
16
|
+
* reconnected.
|
|
17
|
+
*/
|
|
18
|
+
readonly connectorId?: string;
|
|
19
|
+
constructor(message: string, options?: ConnectErrorOptions & {
|
|
20
|
+
readonly reason?: string;
|
|
21
|
+
readonly connectorId?: string;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** Another request is provisioning the same connector; retry shortly. */
|
|
25
|
+
export declare class ConnectorProvisioningInProgressError extends ConnectError {
|
|
26
|
+
constructor(message: string, options?: ConnectErrorOptions);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Identifies one provisionable connector reference: the authored UID, the
|
|
30
|
+
* connection's server URL, the Vercel API it is provisioned against, and an
|
|
31
|
+
* explicitly configured Vercel token. The ambient deployment OIDC token is
|
|
32
|
+
* not part of the scope: it always belongs to the same project, and leaving
|
|
33
|
+
* it out keeps lookups synchronous so cached token reads need no OIDC token.
|
|
34
|
+
*/
|
|
35
|
+
export interface ProvisionScope {
|
|
36
|
+
readonly key: string;
|
|
4
37
|
readonly connector: string;
|
|
5
|
-
readonly
|
|
6
|
-
readonly
|
|
38
|
+
readonly serverUrl: string;
|
|
39
|
+
readonly endpoint: string;
|
|
40
|
+
readonly vercelToken: string | undefined;
|
|
7
41
|
}
|
|
8
|
-
|
|
9
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Returns the provisioning scope for a connector reference, or `undefined`
|
|
44
|
+
* when it cannot be provisioned (opaque ids, reserved UIDs, connections
|
|
45
|
+
* without a URL).
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveProvisionScope(connector: string, connection: EveConnectionAuthorizationContext | undefined, connectOptions: ConnectOptions | undefined): ProvisionScope | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Returns the connector id provisioning recently resolved for `scope`, if
|
|
50
|
+
* any. Synchronous and free of network or OIDC lookups.
|
|
51
|
+
*/
|
|
52
|
+
export declare function getProvisionedConnector(scope: ProvisionScope): string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Returns the connector id recently provisioned for `connector` against the
|
|
55
|
+
* same Vercel API and explicitly configured Vercel token, when exactly one
|
|
56
|
+
* server URL has one. Used to target revocation when the caller did not pass
|
|
57
|
+
* the connection; ambiguous matches return `undefined` so the caller falls
|
|
58
|
+
* back to the UID rather than guessing.
|
|
59
|
+
*/
|
|
60
|
+
export declare function findProvisionedConnector(connector: string, connectOptions: ConnectOptions | undefined): string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Returns every live connector id provisioned for the authored `connector`
|
|
63
|
+
* across scopes, so eviction can drop token cache entries stored under them
|
|
64
|
+
* without knowing the connection.
|
|
65
|
+
*/
|
|
66
|
+
export declare function getProvisionedConnectorIds(connector: string): string[];
|
|
67
|
+
/**
|
|
68
|
+
* Drops `id` for `scope` after Vercel Connect rejected it. Leaves the entry
|
|
69
|
+
* alone when another caller has already replaced it.
|
|
70
|
+
*/
|
|
71
|
+
export declare function forgetProvisionedConnector(scope: ProvisionScope, id: string): void;
|
|
72
|
+
/**
|
|
73
|
+
* Resolves or provisions the managed OAuth connector `scope` refers to and
|
|
74
|
+
* returns its connector id. The authored connector is sent as the
|
|
75
|
+
* project-local UID (`projectUid`), so Vercel Connect resolves it within the
|
|
76
|
+
* deployment's project and environment rather than across the team. Teams
|
|
77
|
+
* without project UIDs fall back to provisioning by team-global UID.
|
|
78
|
+
*
|
|
79
|
+
* The returned id is remembered for {@link getProvisionedConnector}.
|
|
80
|
+
* Returns `undefined` for non-OAuth connectors or when Vercel Connect does
|
|
81
|
+
* not report an id.
|
|
82
|
+
*/
|
|
83
|
+
export declare function provisionEveOAuthConnector(scope: ProvisionScope, connectOptions: ConnectOptions | undefined): Promise<string | undefined>;
|
|
@@ -6,37 +6,186 @@ const RESERVED_UID_PATTERN = /^(vc\/|[^/]*\.vercel\.com\/)/;
|
|
|
6
6
|
const ALLOWED_RESERVED_UID_PREFIXES = ['mcp.vercel.com/'];
|
|
7
7
|
const RESERVED_ID_PREFIXES = ['scl_', 'sca_', 'store_', 'ir_'];
|
|
8
8
|
const INVALID_UID_CHARS = /[\s%#]/;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Vercel Connect could not resolve the connection's project UID to a
|
|
11
|
+
* connector without user-authorized setup, such as enabling the connector
|
|
12
|
+
* for this environment or choosing between ambiguous connectors. Retrying
|
|
13
|
+
* the request does not help until the setup is done.
|
|
14
|
+
*/
|
|
15
|
+
export class ConnectorSetupRequiredError extends ConnectError {
|
|
16
|
+
/** Machine-readable cause reported by Vercel Connect. */
|
|
17
|
+
reason;
|
|
18
|
+
/**
|
|
19
|
+
* Connector the setup applies to, when Vercel Connect identifies one,
|
|
20
|
+
* such as a connector that was removed from the project and can be
|
|
21
|
+
* reconnected.
|
|
22
|
+
*/
|
|
23
|
+
connectorId;
|
|
24
|
+
constructor(message, options = {}) {
|
|
25
|
+
super(message, options);
|
|
26
|
+
this.name = 'ConnectorSetupRequiredError';
|
|
27
|
+
this.reason = options.reason;
|
|
28
|
+
this.connectorId = options.connectorId;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Another request is provisioning the same connector; retry shortly. */
|
|
32
|
+
export class ConnectorProvisioningInProgressError extends ConnectError {
|
|
33
|
+
constructor(message, options) {
|
|
34
|
+
super(message, options);
|
|
35
|
+
this.name = 'ConnectorProvisioningInProgressError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* How long a provisioned connector id is preferred over its UID. Vercel
|
|
40
|
+
* Connect resolves UIDs through eventually consistent indexes, so a connector
|
|
41
|
+
* created moments ago may not resolve by UID yet; the opaque id does. Once
|
|
42
|
+
* the indexes have caught up the UID resolves to the same connector, so
|
|
43
|
+
* dropping back to it picks up later changes such as a user remapping the
|
|
44
|
+
* UID to another connector.
|
|
45
|
+
*/
|
|
46
|
+
const PROVISIONED_CONNECTOR_TTL_MS = 5 * 60 * 1000;
|
|
47
|
+
const MAX_PROVISIONED_CONNECTORS = 100;
|
|
48
|
+
/** Provisioned connector ids by {@link ProvisionScope.key}. */
|
|
49
|
+
const provisionedConnectors = new Map();
|
|
50
|
+
/**
|
|
51
|
+
* In-flight provisioning requests by {@link ProvisionScope.key}. Concurrent
|
|
52
|
+
* callers share one request; entries are dropped as soon as the request
|
|
53
|
+
* settles.
|
|
54
|
+
*/
|
|
55
|
+
const inFlightProvisioning = new Map();
|
|
56
|
+
/**
|
|
57
|
+
* Returns the provisioning scope for a connector reference, or `undefined`
|
|
58
|
+
* when it cannot be provisioned (opaque ids, reserved UIDs, connections
|
|
59
|
+
* without a URL).
|
|
60
|
+
*/
|
|
61
|
+
export function resolveProvisionScope(connector, connection, connectOptions) {
|
|
62
|
+
const serverUrl = connection === undefined ? undefined : resolveServerUrl(connection);
|
|
12
63
|
if (serverUrl === undefined || !isProvisionableConnectorUid(connector)) {
|
|
13
|
-
return;
|
|
64
|
+
return undefined;
|
|
14
65
|
}
|
|
15
|
-
const vercelToken = connectOptions?.vercelToken ?? (await getVercelOidcToken());
|
|
16
66
|
const endpoint = `${resolveBaseUrl(connectOptions)}${MANAGED_OAUTH_CONNECTOR_PATH}`;
|
|
17
|
-
const
|
|
67
|
+
const key = JSON.stringify({
|
|
18
68
|
connector,
|
|
19
69
|
serverUrl,
|
|
20
70
|
endpoint,
|
|
21
|
-
token:
|
|
71
|
+
token: connectOptions?.vercelToken ?? null,
|
|
22
72
|
});
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
73
|
+
return {
|
|
74
|
+
key,
|
|
75
|
+
connector,
|
|
76
|
+
serverUrl,
|
|
77
|
+
endpoint,
|
|
78
|
+
vercelToken: connectOptions?.vercelToken,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Returns the connector id provisioning recently resolved for `scope`, if
|
|
83
|
+
* any. Synchronous and free of network or OIDC lookups.
|
|
84
|
+
*/
|
|
85
|
+
export function getProvisionedConnector(scope) {
|
|
86
|
+
const entry = provisionedConnectors.get(scope.key);
|
|
87
|
+
if (entry === undefined) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (entry.expiresAt <= Date.now()) {
|
|
91
|
+
provisionedConnectors.delete(scope.key);
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
return entry.id;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Returns the connector id recently provisioned for `connector` against the
|
|
98
|
+
* same Vercel API and explicitly configured Vercel token, when exactly one
|
|
99
|
+
* server URL has one. Used to target revocation when the caller did not pass
|
|
100
|
+
* the connection; ambiguous matches return `undefined` so the caller falls
|
|
101
|
+
* back to the UID rather than guessing.
|
|
102
|
+
*/
|
|
103
|
+
export function findProvisionedConnector(connector, connectOptions) {
|
|
104
|
+
const endpoint = `${resolveBaseUrl(connectOptions)}${MANAGED_OAUTH_CONNECTOR_PATH}`;
|
|
105
|
+
const vercelToken = connectOptions?.vercelToken;
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const ids = new Set();
|
|
108
|
+
for (const entry of provisionedConnectors.values()) {
|
|
109
|
+
if (entry.connector === connector &&
|
|
110
|
+
entry.endpoint === endpoint &&
|
|
111
|
+
entry.vercelToken === vercelToken &&
|
|
112
|
+
entry.expiresAt > now) {
|
|
113
|
+
ids.add(entry.id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return ids.size === 1 ? ids.values().next().value : undefined;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Returns every live connector id provisioned for the authored `connector`
|
|
120
|
+
* across scopes, so eviction can drop token cache entries stored under them
|
|
121
|
+
* without knowing the connection.
|
|
122
|
+
*/
|
|
123
|
+
export function getProvisionedConnectorIds(connector) {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const ids = new Set();
|
|
126
|
+
for (const entry of provisionedConnectors.values()) {
|
|
127
|
+
if (entry.connector === connector && entry.expiresAt > now) {
|
|
128
|
+
ids.add(entry.id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return [...ids];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Drops `id` for `scope` after Vercel Connect rejected it. Leaves the entry
|
|
135
|
+
* alone when another caller has already replaced it.
|
|
136
|
+
*/
|
|
137
|
+
export function forgetProvisionedConnector(scope, id) {
|
|
138
|
+
if (provisionedConnectors.get(scope.key)?.id === id) {
|
|
139
|
+
provisionedConnectors.delete(scope.key);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Resolves or provisions the managed OAuth connector `scope` refers to and
|
|
144
|
+
* returns its connector id. The authored connector is sent as the
|
|
145
|
+
* project-local UID (`projectUid`), so Vercel Connect resolves it within the
|
|
146
|
+
* deployment's project and environment rather than across the team. Teams
|
|
147
|
+
* without project UIDs fall back to provisioning by team-global UID.
|
|
148
|
+
*
|
|
149
|
+
* The returned id is remembered for {@link getProvisionedConnector}.
|
|
150
|
+
* Returns `undefined` for non-OAuth connectors or when Vercel Connect does
|
|
151
|
+
* not report an id.
|
|
152
|
+
*/
|
|
153
|
+
export function provisionEveOAuthConnector(scope, connectOptions) {
|
|
154
|
+
const existing = inFlightProvisioning.get(scope.key);
|
|
155
|
+
if (existing !== undefined) {
|
|
156
|
+
return existing;
|
|
157
|
+
}
|
|
158
|
+
const promise = (async () => {
|
|
159
|
+
const vercelToken = connectOptions?.vercelToken ?? (await getVercelOidcToken());
|
|
160
|
+
const id = await provisionManagedOAuthConnector(scope, vercelToken);
|
|
161
|
+
if (id !== undefined) {
|
|
162
|
+
rememberProvisionedConnector(scope, id);
|
|
163
|
+
}
|
|
164
|
+
return id;
|
|
165
|
+
})().finally(() => {
|
|
166
|
+
if (inFlightProvisioning.get(scope.key) === promise) {
|
|
167
|
+
inFlightProvisioning.delete(scope.key);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
inFlightProvisioning.set(scope.key, promise);
|
|
171
|
+
return promise;
|
|
172
|
+
}
|
|
173
|
+
function rememberProvisionedConnector(scope, id) {
|
|
174
|
+
provisionedConnectors.delete(scope.key);
|
|
175
|
+
provisionedConnectors.set(scope.key, {
|
|
176
|
+
connector: scope.connector,
|
|
177
|
+
endpoint: scope.endpoint,
|
|
178
|
+
vercelToken: scope.vercelToken,
|
|
179
|
+
id,
|
|
180
|
+
expiresAt: Date.now() + PROVISIONED_CONNECTOR_TTL_MS,
|
|
181
|
+
});
|
|
182
|
+
while (provisionedConnectors.size > MAX_PROVISIONED_CONNECTORS) {
|
|
183
|
+
const oldest = provisionedConnectors.keys().next().value;
|
|
184
|
+
if (oldest === undefined) {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
provisionedConnectors.delete(oldest);
|
|
38
188
|
}
|
|
39
|
-
await promise;
|
|
40
189
|
}
|
|
41
190
|
function resolveServerUrl(connection) {
|
|
42
191
|
const url = connection.url;
|
|
@@ -66,35 +215,107 @@ function isProvisionableConnectorUid(connector) {
|
|
|
66
215
|
}
|
|
67
216
|
return true;
|
|
68
217
|
}
|
|
69
|
-
async function
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
218
|
+
async function provisionManagedOAuthConnector(scope, vercelToken) {
|
|
219
|
+
const response = await postManagedOAuthConnector(scope, vercelToken, {
|
|
220
|
+
serverUrl: scope.serverUrl,
|
|
221
|
+
projectUid: scope.connector,
|
|
222
|
+
});
|
|
223
|
+
if (response.ok) {
|
|
224
|
+
return connectorIdFromResponse(response);
|
|
74
225
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
226
|
+
const setup = await setupDetailsFromResponse(response.clone());
|
|
227
|
+
const error = await createConnectErrorFromResponse(response, 'Failed to provision connector');
|
|
228
|
+
if (isProjectUidUnsupported(error)) {
|
|
229
|
+
return provisionByTeamUid(scope, vercelToken);
|
|
79
230
|
}
|
|
80
|
-
|
|
231
|
+
throw toProvisioningError(error, setup);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Teams without project-local connector UIDs reject `projectUid` with
|
|
235
|
+
* `404 project_uid_unavailable`; Connect versions that predate OIDC
|
|
236
|
+
* project UIDs reject it with `403`. Explicit user or team API tokens carry
|
|
237
|
+
* no deployment project, so Connect rejects `projectUid` with `400` for
|
|
238
|
+
* them. Provision by team-global UID as before in all of these cases.
|
|
239
|
+
*/
|
|
240
|
+
function isProjectUidUnsupported(error) {
|
|
241
|
+
return ((error.status === 404 && error.code === 'project_uid_unavailable') ||
|
|
242
|
+
(error.status === 400 &&
|
|
243
|
+
error.code === 'bad_request' &&
|
|
244
|
+
/"projectUid" requires "projectId"/.test(error.message)) ||
|
|
245
|
+
(error.status === 403 &&
|
|
246
|
+
error.code === 'forbidden' &&
|
|
247
|
+
/"projectUid" is not allowed/.test(error.message)));
|
|
81
248
|
}
|
|
82
|
-
function
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
249
|
+
async function provisionByTeamUid(scope, vercelToken) {
|
|
250
|
+
const response = await postManagedOAuthConnector(scope, vercelToken, {
|
|
251
|
+
serverUrl: scope.serverUrl,
|
|
252
|
+
uid: scope.connector,
|
|
253
|
+
});
|
|
254
|
+
if (response.ok) {
|
|
255
|
+
return connectorIdFromResponse(response);
|
|
256
|
+
}
|
|
257
|
+
const error = await createConnectErrorFromResponse(response, 'Failed to provision connector');
|
|
258
|
+
if (error.status === 409 && /not an OAuth connector/i.test(error.message)) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
throw error;
|
|
86
262
|
}
|
|
87
|
-
|
|
88
|
-
|
|
263
|
+
function postManagedOAuthConnector(scope, vercelToken, body) {
|
|
264
|
+
return fetch(scope.endpoint, {
|
|
89
265
|
method: 'POST',
|
|
90
266
|
headers: {
|
|
91
267
|
Accept: 'application/json',
|
|
92
268
|
'Content-Type': 'application/json',
|
|
93
269
|
Authorization: `Bearer ${vercelToken}`,
|
|
94
270
|
},
|
|
95
|
-
body: JSON.stringify(
|
|
271
|
+
body: JSON.stringify(body),
|
|
96
272
|
});
|
|
97
|
-
|
|
98
|
-
|
|
273
|
+
}
|
|
274
|
+
async function connectorIdFromResponse(response) {
|
|
275
|
+
try {
|
|
276
|
+
const body = await response.json();
|
|
277
|
+
return isRecord(body) && typeof body.id === 'string' && body.id !== ''
|
|
278
|
+
? body.id
|
|
279
|
+
: undefined;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function toProvisioningError(error, setup) {
|
|
286
|
+
const options = {
|
|
287
|
+
code: error.code,
|
|
288
|
+
status: error.status,
|
|
289
|
+
statusText: error.statusText,
|
|
290
|
+
vendor: error.vendor,
|
|
291
|
+
};
|
|
292
|
+
if (error.code === 'connector_setup_required') {
|
|
293
|
+
return new ConnectorSetupRequiredError(error.message, {
|
|
294
|
+
...options,
|
|
295
|
+
...setup,
|
|
296
|
+
});
|
|
99
297
|
}
|
|
298
|
+
if (error.code === 'connector_provisioning_in_progress') {
|
|
299
|
+
return new ConnectorProvisioningInProgressError(error.message, options);
|
|
300
|
+
}
|
|
301
|
+
return error;
|
|
302
|
+
}
|
|
303
|
+
async function setupDetailsFromResponse(response) {
|
|
304
|
+
try {
|
|
305
|
+
const body = await response.json();
|
|
306
|
+
if (!isRecord(body) || !isRecord(body.error)) {
|
|
307
|
+
return {};
|
|
308
|
+
}
|
|
309
|
+
const { reason, connectorId } = body.error;
|
|
310
|
+
return {
|
|
311
|
+
...(typeof reason === 'string' ? { reason } : {}),
|
|
312
|
+
...(typeof connectorId === 'string' ? { connectorId } : {}),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return {};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function isRecord(value) {
|
|
320
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
100
321
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vercel/connect",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"dist"
|
|
51
51
|
],
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@vercel/oidc": "4.0.5
|
|
53
|
+
"@vercel/oidc": "4.0.5"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"@ai-sdk/mcp": "^1 || ^2",
|