@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
- const { url, state, pkceVerifier } = this.deps.oauth.buildAuthorizeUrl(def.oauth, redirectUri);
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(cfg.authorizeUrl);
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
- async postToken(cfg, body) {
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(cfg.tokenUrl, {
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 ${cfg.tokenUrl} returned ${res.status}: ${text}`);
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 ${cfg.tokenUrl} returned a response without an access token. ` +
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.1",
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",