@agentforge-io/core 2.0.16 → 2.0.17
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.
|
@@ -28,11 +28,45 @@ export interface ConnectorToolFactory {
|
|
|
28
28
|
definition: Omit<AgentToolDefinition, 'execute'>;
|
|
29
29
|
build: (ctx: ConnectorToolContext) => AgentToolDefinition['execute'];
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* UI hints for connectors that authenticate via a user-pasted API key
|
|
33
|
+
* instead of the OAuth2 dance (Granola — and any future provider whose
|
|
34
|
+
* vendor doesn't ship OAuth for third-party apps).
|
|
35
|
+
*
|
|
36
|
+
* The runtime never reads these fields itself; they exist so the host's
|
|
37
|
+
* directory UI can render a sensible "Paste your key" panel without
|
|
38
|
+
* hard-coding per-provider copy. `expectedPrefix` is also used by the
|
|
39
|
+
* registry as a cheap sanity check before persisting — a key that doesn't
|
|
40
|
+
* start with `grn_` is almost certainly a paste mistake.
|
|
41
|
+
*/
|
|
42
|
+
export interface ApiKeyAuthConfig {
|
|
43
|
+
/** URL to the provider's docs page that explains where the user generates
|
|
44
|
+
* the API key (e.g. Granola's Settings → Connectors → API keys). Shown
|
|
45
|
+
* as a "Where do I get this?" link in the UI. */
|
|
46
|
+
instructionsUrl?: string;
|
|
47
|
+
/** Placeholder text shown inside the input field. */
|
|
48
|
+
placeholder?: string;
|
|
49
|
+
/** If set, the registry rejects keys that don't start with this prefix —
|
|
50
|
+
* Granola's keys are `grn_…`, so a paste that doesn't start that way is
|
|
51
|
+
* almost always a wrong copy. Skip the check by leaving this undefined. */
|
|
52
|
+
expectedPrefix?: string;
|
|
53
|
+
}
|
|
31
54
|
/**
|
|
32
55
|
* Static connector definition. Lives in code (option 1 from the design
|
|
33
56
|
* discussion): one of these per supported provider, registered into the
|
|
34
57
|
* `ConnectorRegistry` at boot. The host wires `clientId` / `clientSecret`
|
|
35
58
|
* from env into the `oauth` config before registering.
|
|
59
|
+
*
|
|
60
|
+
* Connectors come in two auth flavors:
|
|
61
|
+
*
|
|
62
|
+
* - **OAuth2** (Google, Slack, Notion, …) — set `oauth`. The registry
|
|
63
|
+
* drives the authorize → exchange → refresh dance.
|
|
64
|
+
* - **API key** (Granola, …) — set `apiKey`. The user pastes their key
|
|
65
|
+
* into the UI; the registry persists it via the same encrypted column
|
|
66
|
+
* as an OAuth access token (no refresh, no expiry).
|
|
67
|
+
*
|
|
68
|
+
* Exactly one of `oauth` / `apiKey` must be present. The registry throws
|
|
69
|
+
* on `register()` if both are set or neither is.
|
|
36
70
|
*/
|
|
37
71
|
export interface ConnectorDefinition {
|
|
38
72
|
/** Stable slug used in URLs and the DB (`google`, `slack`, …). */
|
|
@@ -45,7 +79,10 @@ export interface ConnectorDefinition {
|
|
|
45
79
|
category?: string;
|
|
46
80
|
/** Optional URL of a logo asset served by the host. */
|
|
47
81
|
iconUrl?: string;
|
|
48
|
-
oauth
|
|
82
|
+
/** OAuth2 connector config. Exactly one of `oauth` / `apiKey` is set. */
|
|
83
|
+
oauth?: OAuth2ProviderConfig;
|
|
84
|
+
/** API-key connector config. Exactly one of `oauth` / `apiKey` is set. */
|
|
85
|
+
apiKey?: ApiKeyAuthConfig;
|
|
49
86
|
/** Tools this connector contributes to the agent's toolbelt once a user
|
|
50
87
|
* has authorized. */
|
|
51
88
|
tools: ConnectorToolFactory[];
|
|
@@ -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';
|
|
8
|
+
code: 'unknown_connector' | 'not_connected' | 'not_configured' | 'invalid_state' | 'token_exchange_failed' | 'invalid_api_key' | 'wrong_auth_kind';
|
|
9
9
|
constructor(code: ConnectorError['code'], message: string);
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
@@ -141,6 +141,19 @@ export declare class ConnectorRegistryService {
|
|
|
141
141
|
connectorId: string;
|
|
142
142
|
userId: string;
|
|
143
143
|
}>;
|
|
144
|
+
/**
|
|
145
|
+
* Persist a user-pasted API key as the connector credential for `userId`.
|
|
146
|
+
* Replaces any existing row (so reconnecting with a fresh key just works).
|
|
147
|
+
*
|
|
148
|
+
* The key is treated exactly like an OAuth access token from this point
|
|
149
|
+
* on: encrypted at rest via the same cipher, never expires (no refresh
|
|
150
|
+
* dance), and surfaced through `getAccessToken` for tool calls.
|
|
151
|
+
*
|
|
152
|
+
* Validation here is intentionally minimal — prefix check + non-empty. A
|
|
153
|
+
* provider ping ("does this key actually work?") belongs in the
|
|
154
|
+
* controller, where the per-connector HTTP client lives.
|
|
155
|
+
*/
|
|
156
|
+
saveApiKey(connectorId: string, userId: string, apiKey: string): Promise<void>;
|
|
144
157
|
listForUser(userId: string): Promise<ConnectorStatus[]>;
|
|
145
158
|
disconnect(userId: string, connectorId: string): Promise<void>;
|
|
146
159
|
/**
|
|
@@ -70,6 +70,15 @@ class ConnectorRegistryService {
|
|
|
70
70
|
if (this.defs.has(def.id)) {
|
|
71
71
|
throw new Error(`Connector "${def.id}" already registered`);
|
|
72
72
|
}
|
|
73
|
+
// Exactly one auth flavor. Catching this at register-time avoids
|
|
74
|
+
// confusing runtime errors deep inside startAuthorize / saveApiKey when
|
|
75
|
+
// the wrong branch is taken on a half-built def.
|
|
76
|
+
const hasOauth = !!def.oauth;
|
|
77
|
+
const hasApiKey = !!def.apiKey;
|
|
78
|
+
if (hasOauth === hasApiKey) {
|
|
79
|
+
throw new Error(`Connector "${def.id}" must declare exactly one of \`oauth\` or \`apiKey\`` +
|
|
80
|
+
` (got ${hasOauth ? 'both' : 'neither'}).`);
|
|
81
|
+
}
|
|
73
82
|
this.defs.set(def.id, def);
|
|
74
83
|
}
|
|
75
84
|
/**
|
|
@@ -118,6 +127,14 @@ class ConnectorRegistryService {
|
|
|
118
127
|
// ─── OAuth flow ──────────────────────────────────────────────────────────
|
|
119
128
|
async startAuthorize(connectorId, userId, redirectUri) {
|
|
120
129
|
const def = this.get(connectorId);
|
|
130
|
+
if (!def.oauth) {
|
|
131
|
+
// API-key connectors don't run an OAuth dance. The controller is
|
|
132
|
+
// expected to route the request to `saveApiKey` instead — surface a
|
|
133
|
+
// distinct error code so the UI can render the right panel rather
|
|
134
|
+
// than retrying authorize.
|
|
135
|
+
throw new ConnectorError('wrong_auth_kind', `Connector "${connectorId}" authenticates with an API key, not OAuth. ` +
|
|
136
|
+
`Use POST /connectors/oauth/${connectorId}/api-key instead.`);
|
|
137
|
+
}
|
|
121
138
|
if (!this.configured.has(connectorId)) {
|
|
122
139
|
throw new ConnectorError('not_configured', `Connector "${connectorId}" is not configured — the operator must ` +
|
|
123
140
|
`set its OAuth credentials before users can connect.`);
|
|
@@ -144,6 +161,14 @@ class ConnectorRegistryService {
|
|
|
144
161
|
throw new ConnectorError('invalid_state', 'OAuth state is unknown or expired');
|
|
145
162
|
}
|
|
146
163
|
const def = this.get(ctx.connectorId);
|
|
164
|
+
if (!def.oauth) {
|
|
165
|
+
// Belt + suspenders. `startAuthorize` already gates on this, but if
|
|
166
|
+
// an operator swapped an OAuth connector for an API-key one between
|
|
167
|
+
// the authorize redirect and the callback, the state would still be
|
|
168
|
+
// valid and we'd crash here. Fail loud.
|
|
169
|
+
throw new ConnectorError('wrong_auth_kind', `Connector "${ctx.connectorId}" no longer uses OAuth — the user must ` +
|
|
170
|
+
`re-connect using the API key flow.`);
|
|
171
|
+
}
|
|
147
172
|
let tokens;
|
|
148
173
|
try {
|
|
149
174
|
tokens = await this.deps.oauth.exchangeCode(def.oauth, code, redirectUri, ctx.pkceVerifier);
|
|
@@ -154,6 +179,36 @@ class ConnectorRegistryService {
|
|
|
154
179
|
await this.upsertAuth(ctx.userId, ctx.connectorId, tokens);
|
|
155
180
|
return { connectorId: ctx.connectorId, userId: ctx.userId };
|
|
156
181
|
}
|
|
182
|
+
// ─── API-key flow ────────────────────────────────────────────────────────
|
|
183
|
+
/**
|
|
184
|
+
* Persist a user-pasted API key as the connector credential for `userId`.
|
|
185
|
+
* Replaces any existing row (so reconnecting with a fresh key just works).
|
|
186
|
+
*
|
|
187
|
+
* The key is treated exactly like an OAuth access token from this point
|
|
188
|
+
* on: encrypted at rest via the same cipher, never expires (no refresh
|
|
189
|
+
* dance), and surfaced through `getAccessToken` for tool calls.
|
|
190
|
+
*
|
|
191
|
+
* Validation here is intentionally minimal — prefix check + non-empty. A
|
|
192
|
+
* provider ping ("does this key actually work?") belongs in the
|
|
193
|
+
* controller, where the per-connector HTTP client lives.
|
|
194
|
+
*/
|
|
195
|
+
async saveApiKey(connectorId, userId, apiKey) {
|
|
196
|
+
const def = this.get(connectorId);
|
|
197
|
+
if (!def.apiKey) {
|
|
198
|
+
throw new ConnectorError('wrong_auth_kind', `Connector "${connectorId}" authenticates with OAuth, not an API key.`);
|
|
199
|
+
}
|
|
200
|
+
const trimmed = apiKey.trim();
|
|
201
|
+
if (!trimmed) {
|
|
202
|
+
throw new ConnectorError('invalid_api_key', 'API key is empty.');
|
|
203
|
+
}
|
|
204
|
+
if (def.apiKey.expectedPrefix && !trimmed.startsWith(def.apiKey.expectedPrefix)) {
|
|
205
|
+
throw new ConnectorError('invalid_api_key', `API key for "${connectorId}" must start with "${def.apiKey.expectedPrefix}".`);
|
|
206
|
+
}
|
|
207
|
+
// Synthesize a TokenSet so upsertAuth can be reused unchanged. No
|
|
208
|
+
// expiresIn, no refreshToken — same shape as a long-lived OAuth token
|
|
209
|
+
// (ClickUp, Slack, GitHub, Notion).
|
|
210
|
+
await this.upsertAuth(userId, connectorId, { accessToken: trimmed });
|
|
211
|
+
}
|
|
157
212
|
// ─── Per-user listing ────────────────────────────────────────────────────
|
|
158
213
|
async listForUser(userId) {
|
|
159
214
|
const authed = await this.deps.authRepo.listForUser(userId);
|
|
@@ -285,6 +340,13 @@ class ConnectorRegistryService {
|
|
|
285
340
|
throw new ConnectorError('not_connected', `Token for ${a.connectorId} expired and no refresh token is available; user must re-authorize`);
|
|
286
341
|
}
|
|
287
342
|
const def = this.get(a.connectorId);
|
|
343
|
+
if (!def.oauth) {
|
|
344
|
+
// Unreachable in practice: api-key connectors never persist a
|
|
345
|
+
// refresh token, so the earlier `!a.refreshTokenEncrypted` branch
|
|
346
|
+
// would have caught this. Belt + suspenders for the type checker
|
|
347
|
+
// and for the case where someone manually inserts a row.
|
|
348
|
+
throw new ConnectorError('wrong_auth_kind', `Connector "${a.connectorId}" uses API-key auth; cannot refresh.`);
|
|
349
|
+
}
|
|
288
350
|
const refreshPlain = this.deps.cipher.decrypt(a.refreshTokenEncrypted);
|
|
289
351
|
const fresh = await this.deps.oauth.refresh(def.oauth, refreshPlain);
|
|
290
352
|
const expiresAt = fresh.expiresIn
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentforge-io/core",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.17",
|
|
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",
|