@agentforge-io/core 3.0.1 → 3.3.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.
|
@@ -62,6 +62,18 @@ export interface ConnectorAuth {
|
|
|
62
62
|
/** Soft toggle. UI revoke flips this to false and the runtime stops
|
|
63
63
|
* resolving the token for tool calls. */
|
|
64
64
|
isActive: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Per-installation context captured at authorize-time. Carries the
|
|
67
|
+
* data the tool runtime + refresh path need that is NOT in the token
|
|
68
|
+
* itself: today only `shop` (for Shopify, where the API base is
|
|
69
|
+
* `https://{shop}.myshopify.com/admin/api/...`).
|
|
70
|
+
*
|
|
71
|
+
* Free-form JSONB so future per-installation providers (Atlassian
|
|
72
|
+
* Cloud `cloudId`, Intercom region, etc.) can stamp their own keys
|
|
73
|
+
* without another schema bump. Connectors are responsible for
|
|
74
|
+
* documenting which keys they read.
|
|
75
|
+
*/
|
|
76
|
+
metadata?: Record<string, unknown>;
|
|
65
77
|
createdAt: Date;
|
|
66
78
|
updatedAt: Date;
|
|
67
79
|
}
|
|
@@ -5,7 +5,7 @@ import type { ToolRegistryService } from './tool-registry.service';
|
|
|
5
5
|
import type { AgentToolDefinition } from '../types';
|
|
6
6
|
export declare class ConnectorError extends Error {
|
|
7
7
|
status: number;
|
|
8
|
-
code: 'unknown_connector' | 'not_connected' | 'not_configured' | 'invalid_state' | 'token_exchange_failed' | 'invalid_api_key' | 'wrong_auth_kind';
|
|
8
|
+
code: 'unknown_connector' | 'not_connected' | 'not_configured' | 'invalid_state' | 'token_exchange_failed' | 'invalid_api_key' | 'wrong_auth_kind' | 'missing_installation_context';
|
|
9
9
|
constructor(code: ConnectorError['code'], message: string);
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
@@ -35,6 +35,18 @@ export interface AuthorizeState {
|
|
|
35
35
|
* persisted row and in the UI as "Connected by [Admin Name]".
|
|
36
36
|
*/
|
|
37
37
|
tenantId?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Per-installation domain captured at authorize-time and propagated
|
|
40
|
+
* to the token-exchange step. Only used by providers whose OAuth URLs
|
|
41
|
+
* are per-shop (Shopify); for the other connectors this stays
|
|
42
|
+
* undefined and `OAuth2Service.resolveEndpoints` falls back to the
|
|
43
|
+
* static URLs.
|
|
44
|
+
*
|
|
45
|
+
* Persisted on the resulting `ConnectorAuth.metadata.shop` so the
|
|
46
|
+
* connector's tool runtime + the refresh path can reconstruct the
|
|
47
|
+
* per-shop API base without round-tripping to the user.
|
|
48
|
+
*/
|
|
49
|
+
shop?: string;
|
|
38
50
|
}
|
|
39
51
|
/**
|
|
40
52
|
* Pluggable storage for the short-lived state map. Defaults to an
|
|
@@ -137,6 +149,7 @@ export declare class ConnectorRegistryService {
|
|
|
137
149
|
get(connectorId: string): ConnectorDefinition;
|
|
138
150
|
startAuthorize(connectorId: string, userId: string, redirectUri: string, opts?: {
|
|
139
151
|
tenantId?: string;
|
|
152
|
+
shop?: string;
|
|
140
153
|
}): Promise<{
|
|
141
154
|
url: string;
|
|
142
155
|
}>;
|
|
@@ -220,6 +233,7 @@ export declare class ConnectorRegistryService {
|
|
|
220
233
|
userId: string;
|
|
221
234
|
connectedByUserId?: string;
|
|
222
235
|
accountLabel?: string;
|
|
236
|
+
metadata?: Record<string, unknown>;
|
|
223
237
|
}): Promise<void>;
|
|
224
238
|
/**
|
|
225
239
|
* Encrypt tokens and compute `expiresAt`. Shared by both the
|
|
@@ -139,13 +139,23 @@ class ConnectorRegistryService {
|
|
|
139
139
|
throw new ConnectorError('not_configured', `Connector "${connectorId}" is not configured — the operator must ` +
|
|
140
140
|
`set its OAuth credentials before users can connect.`);
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
// Per-installation providers (Shopify) require a `shop` so the
|
|
143
|
+
// service can pick the right per-shop authorize URL. Fail loud
|
|
144
|
+
// here rather than at the provider — the alternative is a generic
|
|
145
|
+
// "invalid_client" round-trip with no breadcrumb.
|
|
146
|
+
if (def.oauth.resolveEndpoints && !opts?.shop) {
|
|
147
|
+
throw new ConnectorError('missing_installation_context', `Connector "${connectorId}" requires a per-installation domain ` +
|
|
148
|
+
`(e.g. shop) in startAuthorize opts. The connector defines ` +
|
|
149
|
+
`resolveEndpoints but no shop was provided.`);
|
|
150
|
+
}
|
|
151
|
+
const { url, state, pkceVerifier } = this.deps.oauth.buildAuthorizeUrl(def.oauth, redirectUri, opts?.shop ? { shop: opts.shop } : undefined);
|
|
143
152
|
await this.stateStore.set(state, {
|
|
144
153
|
userId,
|
|
145
154
|
connectorId,
|
|
146
155
|
pkceVerifier,
|
|
147
156
|
createdAt: Date.now(),
|
|
148
157
|
tenantId: opts?.tenantId,
|
|
158
|
+
shop: opts?.shop,
|
|
149
159
|
});
|
|
150
160
|
return { url };
|
|
151
161
|
}
|
|
@@ -172,7 +182,7 @@ class ConnectorRegistryService {
|
|
|
172
182
|
}
|
|
173
183
|
let tokens;
|
|
174
184
|
try {
|
|
175
|
-
tokens = await this.deps.oauth.exchangeCode(def.oauth, code, redirectUri, ctx.pkceVerifier);
|
|
185
|
+
tokens = await this.deps.oauth.exchangeCode(def.oauth, code, redirectUri, ctx.pkceVerifier, ctx.shop ? { shop: ctx.shop } : undefined);
|
|
176
186
|
}
|
|
177
187
|
catch (e) {
|
|
178
188
|
throw new ConnectorError('token_exchange_failed', e.message);
|
|
@@ -183,11 +193,16 @@ class ConnectorRegistryService {
|
|
|
183
193
|
// user-scoped grants in the personal lane (one row per
|
|
184
194
|
// `(userId, connectorId)`). Both branches share the cipher + token
|
|
185
195
|
// shape via `buildAuthFields`.
|
|
196
|
+
//
|
|
197
|
+
// For per-installation providers (Shopify) we also stamp
|
|
198
|
+
// `metadata.shop` so the tool runtime + refresh path can
|
|
199
|
+
// reconstruct the per-shop API base without a round-trip.
|
|
200
|
+
const metadata = ctx.shop ? { shop: ctx.shop } : undefined;
|
|
186
201
|
if (ctx.tenantId) {
|
|
187
|
-
await this.upsertAuthForTenant(ctx.tenantId, ctx.connectorId, tokens, { userId: ctx.userId });
|
|
202
|
+
await this.upsertAuthForTenant(ctx.tenantId, ctx.connectorId, tokens, { userId: ctx.userId, metadata });
|
|
188
203
|
}
|
|
189
204
|
else {
|
|
190
|
-
await this.upsertAuth(ctx.userId, ctx.connectorId, tokens);
|
|
205
|
+
await this.upsertAuth(ctx.userId, ctx.connectorId, tokens, { metadata });
|
|
191
206
|
}
|
|
192
207
|
return {
|
|
193
208
|
connectorId: ctx.connectorId,
|
|
@@ -332,13 +347,14 @@ class ConnectorRegistryService {
|
|
|
332
347
|
return tools;
|
|
333
348
|
}
|
|
334
349
|
// ─── Internals ───────────────────────────────────────────────────────────
|
|
335
|
-
async upsertAuth(userId, connectorId, tokens) {
|
|
350
|
+
async upsertAuth(userId, connectorId, tokens, opts) {
|
|
336
351
|
const existing = await this.deps.authRepo.findByUserAndConnector(userId, connectorId);
|
|
337
352
|
const fields = this.buildAuthFields(tokens);
|
|
338
353
|
if (existing) {
|
|
339
354
|
await this.deps.authRepo.update(existing.id, {
|
|
340
355
|
...fields,
|
|
341
356
|
isActive: true,
|
|
357
|
+
...(opts?.metadata ? { metadata: opts.metadata } : {}),
|
|
342
358
|
});
|
|
343
359
|
}
|
|
344
360
|
else {
|
|
@@ -352,6 +368,7 @@ class ConnectorRegistryService {
|
|
|
352
368
|
// satisfies the contract.
|
|
353
369
|
accessTokenEncrypted: fields.accessTokenEncrypted,
|
|
354
370
|
isActive: true,
|
|
371
|
+
metadata: opts?.metadata,
|
|
355
372
|
});
|
|
356
373
|
}
|
|
357
374
|
}
|
|
@@ -378,6 +395,7 @@ class ConnectorRegistryService {
|
|
|
378
395
|
// reflects who last attached the integration.
|
|
379
396
|
connectedByUserId,
|
|
380
397
|
...(opts.accountLabel ? { accountLabel: opts.accountLabel } : {}),
|
|
398
|
+
...(opts.metadata ? { metadata: opts.metadata } : {}),
|
|
381
399
|
});
|
|
382
400
|
}
|
|
383
401
|
else {
|
|
@@ -391,6 +409,7 @@ class ConnectorRegistryService {
|
|
|
391
409
|
accessTokenEncrypted: fields.accessTokenEncrypted,
|
|
392
410
|
accountLabel: opts.accountLabel,
|
|
393
411
|
isActive: true,
|
|
412
|
+
metadata: opts.metadata,
|
|
394
413
|
});
|
|
395
414
|
}
|
|
396
415
|
}
|
|
@@ -54,6 +54,42 @@ export interface OAuth2ProviderConfig {
|
|
|
54
54
|
* every existing connector relies on it.
|
|
55
55
|
*/
|
|
56
56
|
tokenAuth?: 'body' | 'basic';
|
|
57
|
+
/**
|
|
58
|
+
* Optional URL resolver for providers whose OAuth endpoints depend on
|
|
59
|
+
* per-installation context. Shopify is the canonical case: the
|
|
60
|
+
* authorize URL is `https://{shop}.myshopify.com/admin/oauth/authorize`
|
|
61
|
+
* and the token URL is `https://{shop}.myshopify.com/admin/oauth/access_token`
|
|
62
|
+
* — neither can be a static string.
|
|
63
|
+
*
|
|
64
|
+
* When set, the service calls this hook at authorize-time AND
|
|
65
|
+
* token-exchange/refresh-time, using the returned URLs INSTEAD of
|
|
66
|
+
* `authorizeUrl` / `tokenUrl`. The static fields stay required (used
|
|
67
|
+
* as the fallback when no `builderCtx` is passed) so existing call
|
|
68
|
+
* sites don't crash.
|
|
69
|
+
*
|
|
70
|
+
* The returned shape is `{ authorizeUrl, tokenUrl }` — same two fields
|
|
71
|
+
* — because Shopify (and similar providers) move both endpoints to
|
|
72
|
+
* the same per-shop domain. Returning both keeps the resolver atomic:
|
|
73
|
+
* we never get into a state where the authorize URL points at shop A
|
|
74
|
+
* and the token URL points at shop B.
|
|
75
|
+
*/
|
|
76
|
+
resolveEndpoints?: (ctx: OAuthBuilderContext) => {
|
|
77
|
+
authorizeUrl: string;
|
|
78
|
+
tokenUrl: string;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Context handed to `OAuth2ProviderConfig.resolveEndpoints` when the
|
|
83
|
+
* provider's URLs are per-installation. Optional fields so providers
|
|
84
|
+
* can subscribe to whatever they need; today only `shop` is used (by
|
|
85
|
+
* Shopify), but we leave room for future regional providers (Atlassian
|
|
86
|
+
* Cloud, Google Workspace EU, etc.) without another minor bump.
|
|
87
|
+
*/
|
|
88
|
+
export interface OAuthBuilderContext {
|
|
89
|
+
/** Per-installation domain. For Shopify: `tienda.myshopify.com`. */
|
|
90
|
+
shop?: string;
|
|
91
|
+
/** Optional region hint for providers with regional endpoints. */
|
|
92
|
+
region?: string;
|
|
57
93
|
}
|
|
58
94
|
export interface AuthorizeUrlResult {
|
|
59
95
|
url: string;
|
|
@@ -84,9 +120,19 @@ interface OAuth2ServiceDeps {
|
|
|
84
120
|
export declare class OAuth2Service {
|
|
85
121
|
private readonly fetchImpl;
|
|
86
122
|
constructor(deps?: OAuth2ServiceDeps);
|
|
87
|
-
buildAuthorizeUrl(cfg: OAuth2ProviderConfig, redirectUri: string): AuthorizeUrlResult;
|
|
88
|
-
exchangeCode(cfg: OAuth2ProviderConfig, code: string, redirectUri: string, pkceVerifier?: string): Promise<TokenSet>;
|
|
89
|
-
refresh(cfg: OAuth2ProviderConfig, refreshToken: string): Promise<TokenSet>;
|
|
123
|
+
buildAuthorizeUrl(cfg: OAuth2ProviderConfig, redirectUri: string, builderCtx?: OAuthBuilderContext): AuthorizeUrlResult;
|
|
124
|
+
exchangeCode(cfg: OAuth2ProviderConfig, code: string, redirectUri: string, pkceVerifier?: string, builderCtx?: OAuthBuilderContext): Promise<TokenSet>;
|
|
125
|
+
refresh(cfg: OAuth2ProviderConfig, refreshToken: string, builderCtx?: OAuthBuilderContext): Promise<TokenSet>;
|
|
126
|
+
/**
|
|
127
|
+
* Resolve the actual (authorizeUrl, tokenUrl) pair for a given call.
|
|
128
|
+
* When the provider declares `resolveEndpoints`, the resolver is
|
|
129
|
+
* invoked with the per-call context (e.g. `{ shop }` for Shopify);
|
|
130
|
+
* otherwise we fall back to the static fields. This is the single
|
|
131
|
+
* choke point so authorize / exchange / refresh stay in sync — there
|
|
132
|
+
* is no path where authorize uses the per-shop URL but refresh
|
|
133
|
+
* accidentally hits the static one.
|
|
134
|
+
*/
|
|
135
|
+
private resolveEndpoints;
|
|
90
136
|
private postToken;
|
|
91
137
|
}
|
|
92
138
|
export {};
|
|
@@ -12,7 +12,8 @@ class OAuth2Service {
|
|
|
12
12
|
constructor(deps = {}) {
|
|
13
13
|
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
14
14
|
}
|
|
15
|
-
buildAuthorizeUrl(cfg, redirectUri) {
|
|
15
|
+
buildAuthorizeUrl(cfg, redirectUri, builderCtx) {
|
|
16
|
+
const resolved = this.resolveEndpoints(cfg, builderCtx);
|
|
16
17
|
const state = (0, crypto_1.randomBytes)(16).toString('hex');
|
|
17
18
|
const usePkce = cfg.usePkce !== false;
|
|
18
19
|
const params = {
|
|
@@ -30,12 +31,12 @@ class OAuth2Service {
|
|
|
30
31
|
params.code_challenge = challenge;
|
|
31
32
|
params.code_challenge_method = 'S256';
|
|
32
33
|
}
|
|
33
|
-
const url = new URL(
|
|
34
|
+
const url = new URL(resolved.authorizeUrl);
|
|
34
35
|
for (const [k, v] of Object.entries(params))
|
|
35
36
|
url.searchParams.set(k, v);
|
|
36
37
|
return { url: url.toString(), state, pkceVerifier };
|
|
37
38
|
}
|
|
38
|
-
async exchangeCode(cfg, code, redirectUri, pkceVerifier) {
|
|
39
|
+
async exchangeCode(cfg, code, redirectUri, pkceVerifier, builderCtx) {
|
|
39
40
|
const body = new URLSearchParams({
|
|
40
41
|
grant_type: 'authorization_code',
|
|
41
42
|
code,
|
|
@@ -51,9 +52,9 @@ class OAuth2Service {
|
|
|
51
52
|
}
|
|
52
53
|
if (pkceVerifier)
|
|
53
54
|
body.set('code_verifier', pkceVerifier);
|
|
54
|
-
return this.postToken(cfg, body);
|
|
55
|
+
return this.postToken(cfg, body, builderCtx);
|
|
55
56
|
}
|
|
56
|
-
async refresh(cfg, refreshToken) {
|
|
57
|
+
async refresh(cfg, refreshToken, builderCtx) {
|
|
57
58
|
const body = new URLSearchParams({
|
|
58
59
|
grant_type: 'refresh_token',
|
|
59
60
|
refresh_token: refreshToken,
|
|
@@ -62,9 +63,25 @@ class OAuth2Service {
|
|
|
62
63
|
body.set('client_id', cfg.clientId);
|
|
63
64
|
body.set('client_secret', cfg.clientSecret);
|
|
64
65
|
}
|
|
65
|
-
return this.postToken(cfg, body);
|
|
66
|
+
return this.postToken(cfg, body, builderCtx);
|
|
66
67
|
}
|
|
67
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the actual (authorizeUrl, tokenUrl) pair for a given call.
|
|
70
|
+
* When the provider declares `resolveEndpoints`, the resolver is
|
|
71
|
+
* invoked with the per-call context (e.g. `{ shop }` for Shopify);
|
|
72
|
+
* otherwise we fall back to the static fields. This is the single
|
|
73
|
+
* choke point so authorize / exchange / refresh stay in sync — there
|
|
74
|
+
* is no path where authorize uses the per-shop URL but refresh
|
|
75
|
+
* accidentally hits the static one.
|
|
76
|
+
*/
|
|
77
|
+
resolveEndpoints(cfg, builderCtx) {
|
|
78
|
+
if (cfg.resolveEndpoints) {
|
|
79
|
+
return cfg.resolveEndpoints(builderCtx ?? {});
|
|
80
|
+
}
|
|
81
|
+
return { authorizeUrl: cfg.authorizeUrl, tokenUrl: cfg.tokenUrl };
|
|
82
|
+
}
|
|
83
|
+
async postToken(cfg, body, builderCtx) {
|
|
84
|
+
const resolved = this.resolveEndpoints(cfg, builderCtx);
|
|
68
85
|
const headers = {
|
|
69
86
|
'content-type': 'application/x-www-form-urlencoded',
|
|
70
87
|
accept: 'application/json',
|
|
@@ -76,14 +93,14 @@ class OAuth2Service {
|
|
|
76
93
|
const credentials = Buffer.from(`${cfg.clientId}:${cfg.clientSecret}`).toString('base64');
|
|
77
94
|
headers.authorization = `Basic ${credentials}`;
|
|
78
95
|
}
|
|
79
|
-
const res = await this.fetchImpl(
|
|
96
|
+
const res = await this.fetchImpl(resolved.tokenUrl, {
|
|
80
97
|
method: 'POST',
|
|
81
98
|
headers,
|
|
82
99
|
body: body.toString(),
|
|
83
100
|
});
|
|
84
101
|
if (!res.ok) {
|
|
85
102
|
const text = await res.text().catch(() => '');
|
|
86
|
-
throw new Error(`OAuth2 token endpoint ${
|
|
103
|
+
throw new Error(`OAuth2 token endpoint ${resolved.tokenUrl} returned ${res.status}: ${text}`);
|
|
87
104
|
}
|
|
88
105
|
const json = (await res.json());
|
|
89
106
|
const tokens = cfg.tokenExtractor
|
|
@@ -95,7 +112,7 @@ class OAuth2Service {
|
|
|
95
112
|
// here with a descriptive error so the connector author knows their
|
|
96
113
|
// extractor (or the default) missed the token.
|
|
97
114
|
if (!tokens.accessToken) {
|
|
98
|
-
throw new Error(`OAuth2 token endpoint ${
|
|
115
|
+
throw new Error(`OAuth2 token endpoint ${resolved.tokenUrl} returned a response without an access token. ` +
|
|
99
116
|
'If the provider uses a non-standard envelope, supply `tokenExtractor` on the ConnectorDefinition.');
|
|
100
117
|
}
|
|
101
118
|
return tokens;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentforge-io/core",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Framework-free AI runtime SDK. Owns: agent loop (Anthropic), conversations, tools, streaming, agent-job queue, SdkHooks. Identity, billing, infra (email/uploads/secrets) live in the host's modules — not here.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|