@agentforge-io/core 2.0.16 → 2.0.18

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,63 @@ export interface ConnectorToolFactory {
28
28
  definition: Omit<AgentToolDefinition, 'execute'>;
29
29
  build: (ctx: ConnectorToolContext) => AgentToolDefinition['execute'];
30
30
  }
31
+ /**
32
+ * Recommended initial state for a single connector tool when the tenant
33
+ * has no override yet.
34
+ *
35
+ * Same shape as the platform's `ToolPermission` (which is the canonical
36
+ * type stored on `agent.tools` / `automation.tools` / `skill.tools`
37
+ * JSONB). We redeclare it here so `@agentforge-io/core` stays free of any
38
+ * dependency on the host's `platform/` module — connectors are core's
39
+ * concern, ToolPermission is the host's. The shape must stay in sync;
40
+ * platform-side `hydrateToolList` accepts either.
41
+ */
42
+ export type ConnectorToolMode = 'allow' | 'approval' | 'blocked';
43
+ export interface ConnectorToolDefault {
44
+ /** Tool name as it appears in the connector's `tools[]` factory. */
45
+ name: string;
46
+ /** What the runtime should do by default when the LLM invokes this. */
47
+ mode: ConnectorToolMode;
48
+ }
49
+ /**
50
+ * UI hints for connectors that authenticate via a user-pasted API key
51
+ * instead of the OAuth2 dance (Granola — and any future provider whose
52
+ * vendor doesn't ship OAuth for third-party apps).
53
+ *
54
+ * The runtime never reads these fields itself; they exist so the host's
55
+ * directory UI can render a sensible "Paste your key" panel without
56
+ * hard-coding per-provider copy. `expectedPrefix` is also used by the
57
+ * registry as a cheap sanity check before persisting — a key that doesn't
58
+ * start with `grn_` is almost certainly a paste mistake.
59
+ */
60
+ export interface ApiKeyAuthConfig {
61
+ /** URL to the provider's docs page that explains where the user generates
62
+ * the API key (e.g. Granola's Settings → Connectors → API keys). Shown
63
+ * as a "Where do I get this?" link in the UI. */
64
+ instructionsUrl?: string;
65
+ /** Placeholder text shown inside the input field. */
66
+ placeholder?: string;
67
+ /** If set, the registry rejects keys that don't start with this prefix —
68
+ * Granola's keys are `grn_…`, so a paste that doesn't start that way is
69
+ * almost always a wrong copy. Skip the check by leaving this undefined. */
70
+ expectedPrefix?: string;
71
+ }
31
72
  /**
32
73
  * Static connector definition. Lives in code (option 1 from the design
33
74
  * discussion): one of these per supported provider, registered into the
34
75
  * `ConnectorRegistry` at boot. The host wires `clientId` / `clientSecret`
35
76
  * from env into the `oauth` config before registering.
77
+ *
78
+ * Connectors come in two auth flavors:
79
+ *
80
+ * - **OAuth2** (Google, Slack, Notion, …) — set `oauth`. The registry
81
+ * drives the authorize → exchange → refresh dance.
82
+ * - **API key** (Granola, …) — set `apiKey`. The user pastes their key
83
+ * into the UI; the registry persists it via the same encrypted column
84
+ * as an OAuth access token (no refresh, no expiry).
85
+ *
86
+ * Exactly one of `oauth` / `apiKey` must be present. The registry throws
87
+ * on `register()` if both are set or neither is.
36
88
  */
37
89
  export interface ConnectorDefinition {
38
90
  /** Stable slug used in URLs and the DB (`google`, `slack`, …). */
@@ -45,8 +97,24 @@ export interface ConnectorDefinition {
45
97
  category?: string;
46
98
  /** Optional URL of a logo asset served by the host. */
47
99
  iconUrl?: string;
48
- oauth: OAuth2ProviderConfig;
100
+ /** OAuth2 connector config. Exactly one of `oauth` / `apiKey` is set. */
101
+ oauth?: OAuth2ProviderConfig;
102
+ /** API-key connector config. Exactly one of `oauth` / `apiKey` is set. */
103
+ apiKey?: ApiKeyAuthConfig;
49
104
  /** Tools this connector contributes to the agent's toolbelt once a user
50
105
  * has authorized. */
51
106
  tools: ConnectorToolFactory[];
107
+ /**
108
+ * Connector-author's recommended initial mode for each tool. The
109
+ * platform uses this as the fallback when the tenant has not saved a
110
+ * per-tool override yet (no row in `af_connector_tool_defaults`). It
111
+ * also seeds the `/connectors/:id` admin page on first visit.
112
+ *
113
+ * Tools not listed here are assumed `allow`. Tools listed that don't
114
+ * match any `tools[i].definition.name` are ignored by the resolver.
115
+ *
116
+ * Optional. When omitted, every tool defaults to `allow` (matches the
117
+ * historical whitelist semantics).
118
+ */
119
+ defaultToolPermissions?: ConnectorToolDefault[];
52
120
  }
@@ -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.16",
3
+ "version": "2.0.18",
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",