@vercel/connect 0.2.1 → 0.2.3

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.
@@ -82,6 +82,25 @@ export interface EveAuthorizationOptions {
82
82
  * and rely on `@vercel/oidc` auto-discovery.
83
83
  */
84
84
  readonly connectOptions?: ConnectOptions;
85
+ /**
86
+ * Re-validate the grant against Vercel Connect on every `getToken`
87
+ * instead of trusting the in-process token cache.
88
+ *
89
+ * By default `getToken` returns a cached token as long as it has not
90
+ * expired, so a grant the user revoked server-side keeps reaching the
91
+ * tool until the bearer's natural expiry. With `validate: true` each
92
+ * `getToken` bypasses the local cache and re-checks Connect; a revoked
93
+ * grant comes back as `no_token` / `user_authorization_required`, which
94
+ * the adapter maps to {@link ConnectionAuthorizationRequiredError} so
95
+ * Eve re-runs the consent flow rather than calling the tool with a dead
96
+ * token.
97
+ *
98
+ * This trades a Connect round trip per call for freshness — leave it
99
+ * off for high-frequency tools where a short revocation window is
100
+ * acceptable, and turn it on for sensitive actions (payments, deletes,
101
+ * privileged writes) that must never run on a revoked grant.
102
+ */
103
+ readonly validate?: boolean;
85
104
  /**
86
105
  * Custom call-to-action rendered on the
87
106
  * `connection.authorization_required` event. When omitted, Eve
@@ -132,6 +151,33 @@ export interface VercelConnectMetadata {
132
151
  */
133
152
  export type EveConnectAuthorizationDefinition<TAuthorization extends InteractiveAuthorizationDefinition | NonInteractiveAuthorizationDefinition> = TAuthorization & {
134
153
  readonly vercelConnect: VercelConnectMetadata;
154
+ /**
155
+ * Drops the in-process Vercel Connect token cache entry for
156
+ * `principal` so the next `getToken` re-fetches instead of re-serving a
157
+ * rejected bearer. Eve's runtime calls this from its shared eviction
158
+ * path when a resolved token is rejected (a downstream `401` mapped to
159
+ * `requireAuth()`, or an MCP server rejecting the bearer), cascading
160
+ * invalidation from Eve's per-step cache down into this adapter's cache.
161
+ *
162
+ * By default this is a local-cache-only operation: it preserves the
163
+ * underlying Connect grant (and its refresh token), so the next
164
+ * `getToken` can refresh a merely-expired access token without forcing
165
+ * a new consent flow. That is the right default for the automatic
166
+ * `401` cascade, where the rejected bearer is usually just stale.
167
+ *
168
+ * Pass `revoke: true` only when the grant itself is known to be dead
169
+ * and you want it torn down at Vercel Connect (refresh token included)
170
+ * so the next `getToken` surfaces `user_authorization_required` and
171
+ * re-runs consent — e.g. a user-initiated "disconnect this
172
+ * integration" action. Revocation is destructive and best-effort: a
173
+ * failed or duplicate revoke is swallowed so it never masks the error
174
+ * that triggered eviction, and the local cache entry is dropped either
175
+ * way.
176
+ */
177
+ readonly evict: (opts: {
178
+ readonly principal: ConnectionPrincipal;
179
+ readonly revoke?: boolean;
180
+ }) => Promise<void>;
135
181
  };
136
182
  /**
137
183
  * Builds an Eve {@link AuthorizationDefinition} backed by Vercel
@@ -30,14 +30,48 @@
30
30
  */
31
31
  import { ConnectionAuthorizationFailedError, ConnectionAuthorizationRequiredError, } from 'eve/connections';
32
32
  import { startAuthorization } from '../authorization.js';
33
- import { ConnectorInstallationRequiredError, getTokenResponse, NoValidTokenError, UserAuthorizationRequiredError, } from '../token.js';
33
+ import { ConnectorInstallationRequiredError, deleteTokenCacheEntry, getTokenResponse, NoValidTokenError, revokeToken, UserAuthorizationRequiredError, } from '../token.js';
34
34
  export function connect(input) {
35
35
  const options = normalizeAuthorizationOptions(input);
36
36
  const vercelConnect = { connector: options.connector };
37
+ const evict = makeEvict(options);
37
38
  if (options.principalType === 'app') {
38
- return { ...buildNonInteractiveDefinition(options), vercelConnect };
39
+ return { ...buildNonInteractiveDefinition(options), vercelConnect, evict };
39
40
  }
40
- return { ...buildInteractiveDefinition(options), vercelConnect };
41
+ return { ...buildInteractiveDefinition(options), vercelConnect, evict };
42
+ }
43
+ /**
44
+ * Builds the {@link EveConnectAuthorizationDefinition.evict} callback for
45
+ * a connector. Resolves the same token params {@link getToken} uses for
46
+ * `principal`, then drops exactly that cache entry — leaving every other
47
+ * principal's cached token intact.
48
+ *
49
+ * When called with `revoke: true` it instead tears the grant down at
50
+ * Vercel Connect via {@link revokeToken} (best-effort, falling back to a
51
+ * local cache drop if the revoke request fails).
52
+ */
53
+ function makeEvict(options) {
54
+ return async ({ principal, revoke }) => {
55
+ const params = await buildTokenParams(options, principal);
56
+ if (revoke) {
57
+ try {
58
+ // Destructive: tears down the grant at Vercel Connect (refresh
59
+ // token included) and clears the in-process cache. Best-effort —
60
+ // a failed or duplicate revoke must not mask the auth error that
61
+ // triggered eviction.
62
+ await revokeToken(options.connector, {
63
+ subject: params.subject,
64
+ installationId: params.installationId,
65
+ }, options.connectOptions);
66
+ return;
67
+ }
68
+ catch {
69
+ // Fall through to the local cache drop so the rejected bearer is
70
+ // gone even when the server-side revoke failed.
71
+ }
72
+ }
73
+ deleteTokenCacheEntry(options.connector, params);
74
+ };
41
75
  }
42
76
  function normalizeAuthorizationOptions(input) {
43
77
  if (typeof input === 'string') {
@@ -50,7 +84,7 @@ function buildInteractiveDefinition(options) {
50
84
  principalType: 'user',
51
85
  async getToken({ principal }) {
52
86
  try {
53
- const response = await getTokenResponse(options.connector, await buildTokenParams(options, principal), options.connectOptions);
87
+ const response = await getTokenResponse(options.connector, await buildTokenParams(options, principal), getTokenConnectOptions(options));
54
88
  return { token: response.token, expiresAt: response.expiresAt };
55
89
  }
56
90
  catch (error) {
@@ -118,7 +152,7 @@ function buildNonInteractiveDefinition(options) {
118
152
  principalType: 'app',
119
153
  async getToken({ principal }) {
120
154
  try {
121
- const response = await getTokenResponse(options.connector, await buildTokenParams(options, principal), options.connectOptions);
155
+ const response = await getTokenResponse(options.connector, await buildTokenParams(options, principal), getTokenConnectOptions(options));
122
156
  return { token: response.token, expiresAt: response.expiresAt };
123
157
  }
124
158
  catch (error) {
@@ -127,6 +161,18 @@ function buildNonInteractiveDefinition(options) {
127
161
  },
128
162
  };
129
163
  }
164
+ /**
165
+ * Connect SDK options for `getToken` calls. When {@link
166
+ * EveAuthorizationOptions.validate} is set, forces a cache-bypassing
167
+ * re-fetch so a revoked-but-unexpired grant is caught instead of served
168
+ * from the in-process token cache.
169
+ */
170
+ function getTokenConnectOptions(options) {
171
+ if (!options.validate) {
172
+ return options.connectOptions;
173
+ }
174
+ return { ...options.connectOptions, forceRefresh: true };
175
+ }
130
176
  async function buildTokenParams(options, principal) {
131
177
  const toSubject = options.principalToSubject ?? principalToSubject;
132
178
  return {
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { getToken, getTokenResponse, revokeToken, ConnectError, NoValidTokenError, UserAuthorizationRequiredError, ConnectorInstallationRequiredError, type ConnectErrorOptions, type ConnectOptions, type ConnectTokenParams, type ConnectTokenResponse, type ConnectTokenSubject, type ConnectVendorErrorPayload, } from './token.js';
1
+ export { deleteTokenCacheEntry, getToken, getTokenResponse, revokeToken, ConnectError, NoValidTokenError, UserAuthorizationRequiredError, ConnectorInstallationRequiredError, type ConnectErrorOptions, type ConnectOptions, type ConnectTokenParams, type ConnectTokenResponse, type ConnectTokenSubject, type ConnectVendorErrorPayload, } from './token.js';
2
2
  export { startAuthorization, type ConnectAuthorizationOptions, type ConnectAuthorizationResponse, } from './authorization.js';
3
3
  export type { ConnectAuthorizationDetail } from './authorization-details.js';
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { getToken, getTokenResponse, revokeToken, ConnectError, NoValidTokenError, UserAuthorizationRequiredError, ConnectorInstallationRequiredError, } from './token.js';
1
+ export { deleteTokenCacheEntry, getToken, getTokenResponse, revokeToken, ConnectError, NoValidTokenError, UserAuthorizationRequiredError, ConnectorInstallationRequiredError, } from './token.js';
2
2
  export { startAuthorization, } from './authorization.js';
package/dist/token.d.ts CHANGED
@@ -80,6 +80,18 @@ export declare class ConnectorInstallationRequiredError extends ConnectError {
80
80
  }
81
81
  export interface ConnectOptions {
82
82
  vercelToken?: string;
83
+ /**
84
+ * Bypass the in-process token cache and re-fetch from Vercel Connect.
85
+ *
86
+ * The cache normally serves any token that is not within
87
+ * {@link ConnectTokenParams.validityBufferMs} of expiry. That means a
88
+ * grant the user revoked server-side keeps being handed back from the
89
+ * local cache until it expires. Set `forceRefresh` when the caller
90
+ * needs Connect to re-validate the grant on this call — a revoked grant
91
+ * then surfaces as `no_token` / `user_authorization_required` instead of
92
+ * a stale bearer.
93
+ */
94
+ forceRefresh?: boolean;
83
95
  }
84
96
  export declare function getToken(connector: string, params: ConnectTokenParams, options?: ConnectOptions): Promise<string>;
85
97
  export declare function getTokenResponse(connector: string, params: ConnectTokenParams, options?: ConnectOptions): Promise<ConnectTokenResponse>;
@@ -87,4 +99,22 @@ export declare function revokeToken(connector: string, params: {
87
99
  subject: ConnectTokenSubject;
88
100
  installationId?: string;
89
101
  }, options?: ConnectOptions): Promise<void>;
102
+ /**
103
+ * Remove a single cached token entry for `(connector, params)` from the
104
+ * in-process cache.
105
+ *
106
+ * Targeted counterpart to {@link revokeToken}'s `cache.clear()`: it drops
107
+ * exactly the entry {@link getTokenResponse} would serve for these
108
+ * arguments, leaving every other connector/principal untouched. Use it
109
+ * when a credential is known to be bad (the resource server rejected the
110
+ * bearer with a `401`) so the next {@link getTokenResponse} re-fetches
111
+ * instead of re-serving the rejected token — without paying for a Connect
112
+ * round trip on every call the way {@link ConnectOptions.forceRefresh}
113
+ * does.
114
+ *
115
+ * The cache key is derived from `connector` plus every field of `params`,
116
+ * so pass the same `params` used for the original {@link getTokenResponse}
117
+ * call. No-op when no matching entry exists.
118
+ */
119
+ export declare function deleteTokenCacheEntry(connector: string, params: ConnectTokenParams): void;
90
120
  export declare function createConnectErrorFromResponse(response: Response, fallbackMessage: string): Promise<ConnectError>;
package/dist/token.js CHANGED
@@ -37,16 +37,21 @@ export async function getToken(connector, params, options) {
37
37
  }
38
38
  export async function getTokenResponse(connector, params, options) {
39
39
  const bufferMs = params.validityBufferMs ?? DEFAULT_VALIDITY_BUFFER_MS;
40
- const cacheKey = JSON.stringify({ connector, ...params });
41
- const cached = cache.get(cacheKey);
42
- if (cached) {
43
- const now = Date.now();
44
- if (cached.response.expiresAt - now > bufferMs) {
45
- cached.lastUsed = now;
46
- return cached.response;
47
- }
40
+ const cacheKey = tokenCacheKey(connector, params);
41
+ if (options?.forceRefresh) {
48
42
  cache.delete(cacheKey);
49
43
  }
44
+ else {
45
+ const cached = cache.get(cacheKey);
46
+ if (cached) {
47
+ const now = Date.now();
48
+ if (cached.response.expiresAt - now > bufferMs) {
49
+ cached.lastUsed = now;
50
+ return cached.response;
51
+ }
52
+ cache.delete(cacheKey);
53
+ }
54
+ }
50
55
  const vercelToken = options?.vercelToken ?? (await getVercelOidcToken());
51
56
  const endpoint = `https://api.vercel.com/v1/connect/token/${encodeURIComponent(connector)}`;
52
57
  const response = await fetch(endpoint, {
@@ -85,9 +90,37 @@ export async function revokeToken(connector, params, options) {
85
90
  }
86
91
  cache.clear();
87
92
  }
93
+ /**
94
+ * Remove a single cached token entry for `(connector, params)` from the
95
+ * in-process cache.
96
+ *
97
+ * Targeted counterpart to {@link revokeToken}'s `cache.clear()`: it drops
98
+ * exactly the entry {@link getTokenResponse} would serve for these
99
+ * arguments, leaving every other connector/principal untouched. Use it
100
+ * when a credential is known to be bad (the resource server rejected the
101
+ * bearer with a `401`) so the next {@link getTokenResponse} re-fetches
102
+ * instead of re-serving the rejected token — without paying for a Connect
103
+ * round trip on every call the way {@link ConnectOptions.forceRefresh}
104
+ * does.
105
+ *
106
+ * The cache key is derived from `connector` plus every field of `params`,
107
+ * so pass the same `params` used for the original {@link getTokenResponse}
108
+ * call. No-op when no matching entry exists.
109
+ */
110
+ export function deleteTokenCacheEntry(connector, params) {
111
+ cache.delete(tokenCacheKey(connector, params));
112
+ }
88
113
  const DEFAULT_VALIDITY_BUFFER_MS = 30_000;
89
114
  const MAX_CACHE_SIZE = 100;
90
115
  const cache = new Map();
116
+ /**
117
+ * Cache key for a `(connector, params)` pair. Stable across calls with
118
+ * equal arguments so {@link getTokenResponse} and
119
+ * {@link deleteTokenCacheEntry} address the same entry.
120
+ */
121
+ function tokenCacheKey(connector, params) {
122
+ return JSON.stringify({ connector, ...params });
123
+ }
91
124
  function evictLru() {
92
125
  let oldestKey;
93
126
  let oldestTime = Infinity;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/connect",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "repository": {