@agentforge-io/core 2.0.15 → 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: OAuth2ProviderConfig;
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[];
@@ -24,3 +24,25 @@ export interface Message {
24
24
  }
25
25
  export type NewConversation = Pick<Conversation, 'userId' | 'agentId'> & Partial<Omit<Conversation, 'id' | 'createdAt' | 'updatedAt'>>;
26
26
  export type NewMessage = Omit<Message, 'id' | 'createdAt'>;
27
+ /**
28
+ * Minimal payload for the agent-editor's conversation sidebar. The full
29
+ * `Conversation + messages[]` is too heavy to ship for 50 rows; this
30
+ * shape carries the bare minimum the UI needs to render a row and
31
+ * decide which one to load in full.
32
+ *
33
+ * `intent` is the first user message of the conversation, truncated to
34
+ * 120 chars server-side. `null` when the conversation has no user
35
+ * message yet (rare but possible mid-create).
36
+ */
37
+ export interface ConversationListItem {
38
+ id: string;
39
+ userId: string;
40
+ agentId: string;
41
+ status: ConversationStatus;
42
+ intent: string | null;
43
+ messageCount: number;
44
+ totalInputTokens: number;
45
+ totalOutputTokens: number;
46
+ createdAt: Date;
47
+ updatedAt: Date;
48
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ConversationRepository, MessageRepository } from './index';
2
- import type { Conversation, NewConversation, Message, NewMessage } from '../domain/conversation';
2
+ import type { Conversation, ConversationListItem, NewConversation, Message, NewMessage } from '../domain/conversation';
3
3
  import type { ConversationStatus } from '../types/agent.types';
4
4
  export declare class InMemoryConversationRepository implements ConversationRepository {
5
5
  private byId;
@@ -11,6 +11,13 @@ export declare class InMemoryConversationRepository implements ConversationRepos
11
11
  limit?: number;
12
12
  offset?: number;
13
13
  }): Promise<Conversation[]>;
14
+ listForAgent(agentId: string, opts?: {
15
+ limit?: number;
16
+ offset?: number;
17
+ }): Promise<{
18
+ items: ConversationListItem[];
19
+ totalCount: number;
20
+ }>;
14
21
  updateStats(id: string, patch: {
15
22
  status?: ConversationStatus;
16
23
  addInputTokens?: number;
@@ -41,6 +41,32 @@ class InMemoryConversationRepository {
41
41
  const end = opts.limit ? start + opts.limit : undefined;
42
42
  return items.slice(start, end);
43
43
  }
44
+ async listForAgent(agentId, opts = {}) {
45
+ // In-memory variant doesn't have access to the message store —
46
+ // those live in a sibling repo. We return `intent: null` here; the
47
+ // TypeORM impl is the one that materializes intent from messages.
48
+ // Good enough for tests + demos that just want to exercise the
49
+ // listing contract.
50
+ const all = Array.from(this.byId.values()).filter((c) => c.agentId === agentId);
51
+ all.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
52
+ const start = opts.offset ?? 0;
53
+ const end = opts.limit ? start + opts.limit : undefined;
54
+ return {
55
+ items: all.slice(start, end).map((c) => ({
56
+ id: c.id,
57
+ userId: c.userId,
58
+ agentId: c.agentId,
59
+ status: c.status,
60
+ intent: null,
61
+ messageCount: c.messageCount,
62
+ totalInputTokens: c.totalInputTokens,
63
+ totalOutputTokens: c.totalOutputTokens,
64
+ createdAt: c.createdAt,
65
+ updatedAt: c.updatedAt,
66
+ })),
67
+ totalCount: all.length,
68
+ };
69
+ }
44
70
  async updateStats(id, patch) {
45
71
  const c = this.byId.get(id);
46
72
  if (!c)
@@ -1,4 +1,4 @@
1
- import type { Conversation, NewConversation, Message, NewMessage } from '../domain/conversation';
1
+ import type { Conversation, ConversationListItem, NewConversation, Message, NewMessage } from '../domain/conversation';
2
2
  import type { ChatToken, NewChatToken, ChatTokenPatch } from '../domain/chat-token';
3
3
  import type { McpServerRecord, NewMcpServerRecord, McpServerRecordPatch } from '../domain/mcp-server';
4
4
  import type { ConnectorAuth, NewConnectorAuth, ConnectorAuthPatch } from '../domain/connector-auth';
@@ -22,6 +22,26 @@ export interface ConversationRepository {
22
22
  limit?: number;
23
23
  offset?: number;
24
24
  }): Promise<Conversation[]>;
25
+ /**
26
+ * List every conversation an agent has had, regardless of user. Used by
27
+ * the agent-editor's "Conversations" panel — operators need visibility
28
+ * into all traffic the agent saw, not just their own.
29
+ *
30
+ * Tenant scoping is enforced one layer up (host service), since the SDK
31
+ * doesn't carry tenant context. Pass `agentId` only after verifying it
32
+ * belongs to the caller's tenant.
33
+ *
34
+ * The optional `intent` field on each item is the first user message's
35
+ * content, truncated to 120 chars — server-side so the wire payload
36
+ * stays small (the list is for a sidebar, not for replaying messages).
37
+ */
38
+ listForAgent(agentId: string, opts?: {
39
+ limit?: number;
40
+ offset?: number;
41
+ }): Promise<{
42
+ items: ConversationListItem[];
43
+ totalCount: number;
44
+ }>;
25
45
  updateStats(id: string, patch: {
26
46
  status?: ConversationStatus;
27
47
  addInputTokens?: number;
@@ -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.15",
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",