@slashfi/agents-sdk 0.26.2 → 0.27.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.
Files changed (50) hide show
  1. package/dist/agent-definitions/config.d.ts +44 -0
  2. package/dist/agent-definitions/config.d.ts.map +1 -0
  3. package/dist/agent-definitions/config.js +234 -0
  4. package/dist/agent-definitions/config.js.map +1 -0
  5. package/dist/cjs/agent-definitions/config.js +237 -0
  6. package/dist/cjs/agent-definitions/config.js.map +1 -0
  7. package/dist/cjs/codegen.js +27 -2
  8. package/dist/cjs/codegen.js.map +1 -1
  9. package/dist/cjs/index.js +21 -3
  10. package/dist/cjs/index.js.map +1 -1
  11. package/dist/cjs/mcp-client.js +159 -0
  12. package/dist/cjs/mcp-client.js.map +1 -0
  13. package/dist/cjs/pkce.js +49 -0
  14. package/dist/cjs/pkce.js.map +1 -0
  15. package/dist/cjs/registry-consumer.js +217 -2
  16. package/dist/cjs/registry-consumer.js.map +1 -1
  17. package/dist/cjs/server.js +33 -2
  18. package/dist/cjs/server.js.map +1 -1
  19. package/dist/codegen.d.ts +4 -0
  20. package/dist/codegen.d.ts.map +1 -1
  21. package/dist/codegen.js +27 -2
  22. package/dist/codegen.js.map +1 -1
  23. package/dist/index.d.ts +7 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +9 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/mcp-client.d.ts +87 -0
  28. package/dist/mcp-client.d.ts.map +1 -0
  29. package/dist/mcp-client.js +152 -0
  30. package/dist/mcp-client.js.map +1 -0
  31. package/dist/pkce.d.ts +29 -0
  32. package/dist/pkce.d.ts.map +1 -0
  33. package/dist/pkce.js +44 -0
  34. package/dist/pkce.js.map +1 -0
  35. package/dist/registry-consumer.d.ts +4 -0
  36. package/dist/registry-consumer.d.ts.map +1 -1
  37. package/dist/registry-consumer.js +216 -2
  38. package/dist/registry-consumer.js.map +1 -1
  39. package/dist/server.d.ts +13 -0
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +33 -2
  42. package/dist/server.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/agent-definitions/config.ts +318 -0
  45. package/src/codegen.ts +33 -1
  46. package/src/index.ts +34 -2
  47. package/src/mcp-client.ts +230 -0
  48. package/src/pkce.ts +54 -0
  49. package/src/registry-consumer.ts +257 -2
  50. package/src/server.ts +49 -2
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Config Agent (@config)
3
+ *
4
+ * Built-in agent for managing consumer configuration — refs and registries.
5
+ * Replaces @integrations and the LLM-facing parts of @auth.
6
+ *
7
+ * Provides:
8
+ * - add_ref / remove_ref / list_refs for managing agent refs
9
+ * - add_registry for registering new registries
10
+ * - FsStore interface for pluggable filesystem storage (VCS-backed or local)
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * import { createAgentRegistry, createConfigAgent } from '@slashfi/agents-sdk';
15
+ *
16
+ * const registry = createAgentRegistry();
17
+ * registry.register(createConfigAgent({
18
+ * store: myFsStore,
19
+ * }));
20
+ * ```
21
+ */
22
+
23
+ import type { ConsumerConfig, RefEntry } from "../define-config.js";
24
+ import { normalizeRef } from "../define-config.js";
25
+ import { defineAgent, defineTool } from "../define.js";
26
+ import type { AgentDefinition, ToolContext } from "../types.js";
27
+
28
+ // ============================================
29
+ // FsStore Interface
30
+ // ============================================
31
+
32
+ /**
33
+ * Filesystem store for reading/writing consumer configs.
34
+ * The storage engine (VCS, local fs, etc.) is abstracted away.
35
+ */
36
+ export interface FsStore {
37
+ /** Read a file as UTF-8 string. Returns null if not found. */
38
+ readFile(path: string): Promise<string | null>;
39
+ /** Write a file with UTF-8 content. Creates parent dirs if needed. */
40
+ writeFile(path: string, content: string): Promise<void>;
41
+ }
42
+
43
+ // ============================================
44
+ // Config Persistence
45
+ // ============================================
46
+
47
+ const CONFIG_PATH = "consumer-config.json";
48
+
49
+ async function readConfig(store: FsStore): Promise<ConsumerConfig> {
50
+ const content = await store.readFile(CONFIG_PATH);
51
+ if (!content) return {};
52
+ try {
53
+ return JSON.parse(content) as ConsumerConfig;
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ async function writeConfig(
60
+ store: FsStore,
61
+ config: ConsumerConfig,
62
+ ): Promise<void> {
63
+ await store.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
64
+ }
65
+
66
+ // ============================================
67
+ // Config Agent Options
68
+ // ============================================
69
+
70
+ export interface ConfigAgentOptions {
71
+ /** Filesystem store for persisting consumer config */
72
+ store: FsStore;
73
+
74
+ /**
75
+ * Resolve the FsStore for a specific user.
76
+ * When provided, refs are stored per-user.
77
+ * The callerId from ToolContext is passed.
78
+ */
79
+ resolveUserStore?: (callerId: string) => FsStore;
80
+ }
81
+
82
+ // ============================================
83
+ // Create Config Agent
84
+ // ============================================
85
+
86
+ export function createConfigAgent(
87
+ options: ConfigAgentOptions,
88
+ ): AgentDefinition {
89
+ const { store, resolveUserStore } = options;
90
+
91
+ function getStore(ctx: ToolContext): FsStore {
92
+ if (resolveUserStore && ctx.callerId) {
93
+ return resolveUserStore(ctx.callerId);
94
+ }
95
+ return store;
96
+ }
97
+
98
+ // ---- add_ref ----
99
+ const addRefTool = defineTool({
100
+ name: "add_ref",
101
+ description:
102
+ "Add or update an agent ref in the consumer config. " +
103
+ "The ref is persisted to the config store.",
104
+ inputSchema: {
105
+ type: "object" as const,
106
+ properties: {
107
+ ref: {
108
+ type: "string",
109
+ description: 'Agent ref name (e.g. "notion", "linear")',
110
+ },
111
+ as: {
112
+ type: "string",
113
+ description: "Local alias (for multi-instance refs)",
114
+ },
115
+ url: {
116
+ type: "string",
117
+ description: "Direct URL to the agent",
118
+ },
119
+ config: {
120
+ type: "object",
121
+ additionalProperties: { type: "string" },
122
+ description: "Per-instance config (secret URIs or literal values)",
123
+ },
124
+ registry: {
125
+ type: "string",
126
+ description:
127
+ 'Registry to resolve from (e.g. "slash", "mcp", "https")',
128
+ },
129
+ },
130
+ required: ["ref"],
131
+ },
132
+ execute: async (
133
+ input: {
134
+ ref: string;
135
+ as?: string;
136
+ url?: string;
137
+ config?: Record<string, string>;
138
+ registry?: string;
139
+ },
140
+ ctx: ToolContext,
141
+ ) => {
142
+ const fs = getStore(ctx);
143
+ const currentConfig = await readConfig(fs);
144
+
145
+ const entry: RefEntry = {
146
+ ref: input.ref,
147
+ ...(input.as && { as: input.as }),
148
+ ...(input.url && { url: input.url }),
149
+ ...(input.config && { config: input.config }),
150
+ ...(input.registry && { registry: input.registry }),
151
+ };
152
+
153
+ // Upsert: find existing ref by name/alias, replace or append
154
+ const name = input.as ?? input.ref;
155
+ const refs = currentConfig.refs ?? [];
156
+ const existingIdx = refs.findIndex((r) => {
157
+ const normalized = normalizeRef(r);
158
+ return normalized.name === name;
159
+ });
160
+
161
+ if (existingIdx >= 0) {
162
+ refs[existingIdx] = entry;
163
+ } else {
164
+ refs.push(entry);
165
+ }
166
+
167
+ currentConfig.refs = refs;
168
+ await writeConfig(fs, currentConfig);
169
+
170
+ return {
171
+ added: true,
172
+ ref: input.ref,
173
+ name,
174
+ };
175
+ },
176
+ });
177
+
178
+ // ---- remove_ref ----
179
+ const removeRefTool = defineTool({
180
+ name: "remove_ref",
181
+ description: "Remove an agent ref from the consumer config by name or alias.",
182
+ inputSchema: {
183
+ type: "object" as const,
184
+ properties: {
185
+ name: {
186
+ type: "string",
187
+ description: "Ref name or alias to remove",
188
+ },
189
+ },
190
+ required: ["name"],
191
+ },
192
+ execute: async (
193
+ input: { name: string },
194
+ ctx: ToolContext,
195
+ ) => {
196
+ const fs = getStore(ctx);
197
+ const currentConfig = await readConfig(fs);
198
+ const refs = currentConfig.refs ?? [];
199
+
200
+ const before = refs.length;
201
+ currentConfig.refs = refs.filter((r) => {
202
+ const normalized = normalizeRef(r);
203
+ return normalized.name !== input.name;
204
+ });
205
+
206
+ if (currentConfig.refs.length === before) {
207
+ return { removed: false, error: `Ref "${input.name}" not found` };
208
+ }
209
+
210
+ await writeConfig(fs, currentConfig);
211
+ return { removed: true, name: input.name };
212
+ },
213
+ });
214
+
215
+ // ---- list_refs ----
216
+ const listRefsTool = defineTool({
217
+ name: "list_refs",
218
+ description:
219
+ "List all agent refs in the consumer config. " +
220
+ "Returns normalized refs with their names and config.",
221
+ inputSchema: {
222
+ type: "object" as const,
223
+ properties: {},
224
+ },
225
+ execute: async (_input: unknown, ctx: ToolContext) => {
226
+ const fs = getStore(ctx);
227
+ const currentConfig = await readConfig(fs);
228
+ const refs = (currentConfig.refs ?? []).map(normalizeRef);
229
+ return { refs };
230
+ },
231
+ });
232
+
233
+ // ---- add_registry ----
234
+ const addRegistryTool = defineTool({
235
+ name: "add_registry",
236
+ description:
237
+ "Add or update a registry in the consumer config. " +
238
+ "Registries are where refs resolve from.",
239
+ inputSchema: {
240
+ type: "object" as const,
241
+ properties: {
242
+ name: {
243
+ type: "string",
244
+ description: 'Human-readable name (e.g. "slash", "internal")',
245
+ },
246
+ url: {
247
+ type: "string",
248
+ description: "Registry URL",
249
+ },
250
+ auth: {
251
+ type: "object",
252
+ description: "Auth config for the registry",
253
+ properties: {
254
+ type: {
255
+ type: "string",
256
+ enum: ["none", "bearer", "api-key", "jwt"],
257
+ },
258
+ },
259
+ },
260
+ },
261
+ required: ["url"],
262
+ },
263
+ execute: async (
264
+ input: {
265
+ name?: string;
266
+ url: string;
267
+ auth?: { type: string; [key: string]: unknown };
268
+ },
269
+ ctx: ToolContext,
270
+ ) => {
271
+ const fs = getStore(ctx);
272
+ const currentConfig = await readConfig(fs);
273
+
274
+ const registries = currentConfig.registries ?? [];
275
+ const entry = {
276
+ url: input.url,
277
+ ...(input.name && { name: input.name }),
278
+ ...(input.auth && { auth: input.auth as any }),
279
+ };
280
+
281
+ // Upsert by URL
282
+ const existingIdx = registries.findIndex((r) => {
283
+ const url = typeof r === "string" ? r : r.url;
284
+ return url === input.url;
285
+ });
286
+
287
+ if (existingIdx >= 0) {
288
+ registries[existingIdx] = entry;
289
+ } else {
290
+ registries.push(entry);
291
+ }
292
+
293
+ currentConfig.registries = registries;
294
+ await writeConfig(fs, currentConfig);
295
+
296
+ return {
297
+ added: true,
298
+ url: input.url,
299
+ name: input.name ?? new URL(input.url).hostname,
300
+ };
301
+ },
302
+ });
303
+
304
+ // ---- Define the agent ----
305
+ return defineAgent({
306
+ path: "@config",
307
+ entrypoint:
308
+ "Consumer config management. Use add_ref/remove_ref/list_refs to manage agent refs, " +
309
+ "and add_registry to configure registries.",
310
+ config: {
311
+ name: "Config",
312
+ description:
313
+ "Manage consumer config — add/remove/list agent refs and registries. " +
314
+ "Replaces @integrations for connecting to third-party services.",
315
+ },
316
+ tools: [addRefTool, removeRefTool, listRefsTool, addRegistryTool] as any,
317
+ });
318
+ }
package/src/codegen.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
32
32
  import { join, resolve } from "node:path";
33
33
  import type { JsonSchema } from "./types.js";
34
+ import { discoverOAuthMetadata, type OAuthServerMetadata } from "./mcp-client.js";
34
35
 
35
36
  // ============================================
36
37
  // Types
@@ -108,6 +109,9 @@ export interface CodegenResult {
108
109
 
109
110
  /** All generated file paths */
110
111
  files: string[];
112
+
113
+ /** OAuth server metadata (if discovered via .well-known/oauth-authorization-server) */
114
+ oauth?: OAuthServerMetadata;
111
115
  }
112
116
 
113
117
  // ============================================
@@ -614,6 +618,23 @@ function createSseTransport(source: {
614
618
  // Source Parsing
615
619
  // ============================================
616
620
 
621
+ /**
622
+ * Extract the base URL from a server source (for OAuth discovery).
623
+ * Returns null for stdio/command-based sources.
624
+ */
625
+ function resolveServerUrl(source: ServerSource): string | null {
626
+ if (typeof source === "string") {
627
+ if (source.startsWith("http://") || source.startsWith("https://")) {
628
+ return source.replace(/\/sse$/, "");
629
+ }
630
+ return null;
631
+ }
632
+ if ("url" in source) {
633
+ return source.url.replace(/\/sse$/, "");
634
+ }
635
+ return null;
636
+ }
637
+
617
638
  function parseServerSource(source: ServerSource): McpTransport {
618
639
  if (typeof source === "string") {
619
640
  // URL -> HTTP or SSE transport
@@ -1113,6 +1134,7 @@ export interface CodegenManifest {
1113
1134
  serverSource: ServerSource;
1114
1135
  serverInfo: McpServerInfo;
1115
1136
  tools: { name: string; description?: string }[];
1137
+ oauth?: OAuthServerMetadata;
1116
1138
  generatedAt: string;
1117
1139
  }
1118
1140
 
@@ -1121,12 +1143,14 @@ function generateManifest(
1121
1143
  serverInfo: McpServerInfo,
1122
1144
  tools: McpToolDefinition[],
1123
1145
  agentPath: string,
1146
+ oauth?: OAuthServerMetadata | null,
1124
1147
  ): string {
1125
1148
  const manifest: CodegenManifest = {
1126
1149
  agentPath,
1127
1150
  serverSource,
1128
1151
  serverInfo,
1129
1152
  tools: tools.map((t) => ({ name: t.name, description: t.description })),
1153
+ ...(oauth ? { oauth } : {}),
1130
1154
  generatedAt: new Date().toISOString(),
1131
1155
  };
1132
1156
  return JSON.stringify(manifest, null, 2) + "\n";
@@ -1204,6 +1228,13 @@ export async function codegen(options: CodegenOptions): Promise<CodegenResult> {
1204
1228
  );
1205
1229
  }
1206
1230
 
1231
+ // 3.5. Discover OAuth metadata (for URL-based servers)
1232
+ let oauth: OAuthServerMetadata | null = null;
1233
+ const serverUrl = resolveServerUrl(options.server);
1234
+ if (serverUrl) {
1235
+ oauth = await discoverOAuthMetadata(serverUrl);
1236
+ }
1237
+
1207
1238
  // 4. Derive agent path
1208
1239
  const agentPath =
1209
1240
  options.agentPath ??
@@ -1253,7 +1284,7 @@ export async function codegen(options: CodegenOptions): Promise<CodegenResult> {
1253
1284
  }
1254
1285
 
1255
1286
  // 11. Generate manifest (for `agents-sdk use`)
1256
- const manifest = generateManifest(options.server, serverInfo, tools, agentPath);
1287
+ const manifest = generateManifest(options.server, serverInfo, tools, agentPath, oauth);
1257
1288
  writeFileSync(join(outDir, ".codegen-manifest.json"), manifest);
1258
1289
  files.push(".codegen-manifest.json");
1259
1290
 
@@ -1263,6 +1294,7 @@ export async function codegen(options: CodegenOptions): Promise<CodegenResult> {
1263
1294
  toolCount: tools.length,
1264
1295
  toolFiles,
1265
1296
  files,
1297
+ ...(oauth ? { oauth } : {}),
1266
1298
  };
1267
1299
  }
1268
1300
 
package/src/index.ts CHANGED
@@ -211,7 +211,8 @@ export type {
211
211
 
212
212
  // Postgres Secret Store
213
213
 
214
- // Integrations
214
+ // Integrations (DEPRECATED — use createConfigAgent + refs instead)
215
+ /** @deprecated Use createConfigAgent instead */
215
216
  export {
216
217
  createIntegrationsAgent,
217
218
  createInMemoryIntegrationStore,
@@ -280,7 +281,11 @@ export type {
280
281
  ResolvedConfig,
281
282
  } from "./define-config.js";
282
283
 
283
- export { createRegistryConsumer } from "./registry-consumer.js";
284
+ export {
285
+ createRegistryConsumer,
286
+ REGISTRY_TYPE_MCP,
287
+ REGISTRY_TYPE_HTTPS,
288
+ } from "./registry-consumer.js";
284
289
  export type {
285
290
  RegistryConsumer,
286
291
  RegistryConsumerOptions,
@@ -289,6 +294,25 @@ export type {
289
294
  SecretResolver,
290
295
  } from "./registry-consumer.js";
291
296
 
297
+ // PKCE
298
+ export {
299
+ generateCodeVerifier,
300
+ generateCodeChallenge,
301
+ generatePkcePair,
302
+ } from "./pkce.js";
303
+
304
+ // MCP Client Auth (OAuth utilities for connecting to MCP servers/registries)
305
+ export {
306
+ discoverOAuthMetadata,
307
+ dynamicClientRegistration,
308
+ buildOAuthAuthorizeUrl,
309
+ exchangeCodeForTokens,
310
+ refreshAccessToken as refreshMcpAccessToken,
311
+ } from "./mcp-client.js";
312
+ export type {
313
+ OAuthServerMetadata,
314
+ } from "./mcp-client.js";
315
+
292
316
  // Codegen
293
317
  export { codegen, useAgent, listAgentTools } from "./codegen.js";
294
318
  export type {
@@ -375,8 +399,16 @@ export type { ValidationResult } from "./validate.js";
375
399
  export {
376
400
  createBM25Index,
377
401
  } from "./bm25.js";
402
+
378
403
  export type {
379
404
  BM25Options,
380
405
  BM25Document,
381
406
  BM25Result,
382
407
  } from "./bm25.js";
408
+
409
+ // Config Agent
410
+ export { createConfigAgent } from "./agent-definitions/config.js";
411
+ export type {
412
+ ConfigAgentOptions,
413
+ FsStore,
414
+ } from "./agent-definitions/config.js";
@@ -0,0 +1,230 @@
1
+ /**
2
+ * MCP Client Auth — OAuth utilities for connecting to MCP servers.
3
+ *
4
+ * Standalone utilities for:
5
+ * - OAuth Authorization Server discovery (.well-known/oauth-authorization-server, RFC 8414)
6
+ * - Dynamic client registration (RFC 7591)
7
+ * - PKCE OAuth authorization URL construction
8
+ * - Authorization code → token exchange (with PKCE)
9
+ * - Token refresh
10
+ *
11
+ * These are used by registry-consumer.ts when connecting to MCP servers
12
+ * or registries that require OAuth. The MCP transport itself is handled
13
+ * by registry-consumer — this module only provides auth primitives.
14
+ */
15
+
16
+ import { generatePkcePair } from "./pkce.js";
17
+
18
+ // ============================================
19
+ // Types
20
+ // ============================================
21
+
22
+ /** OAuth Authorization Server Metadata (RFC 8414) */
23
+ export interface OAuthServerMetadata {
24
+ issuer: string;
25
+ authorization_endpoint: string;
26
+ token_endpoint: string;
27
+ registration_endpoint?: string;
28
+ scopes_supported?: string[];
29
+ response_types_supported?: string[];
30
+ grant_types_supported?: string[];
31
+ code_challenge_methods_supported?: string[];
32
+ token_endpoint_auth_methods_supported?: string[];
33
+ }
34
+
35
+ // ============================================
36
+ // OAuth Discovery
37
+ // ============================================
38
+
39
+ /**
40
+ * Discover OAuth authorization server metadata.
41
+ * Probes .well-known/oauth-authorization-server (RFC 8414).
42
+ * Returns null if the server doesn't support OAuth.
43
+ */
44
+ export async function discoverOAuthMetadata(
45
+ serverUrl: string,
46
+ fetchFn: typeof globalThis.fetch = globalThis.fetch,
47
+ ): Promise<OAuthServerMetadata | null> {
48
+ const url = `${serverUrl.replace(/\/$/, "")}/.well-known/oauth-authorization-server`;
49
+ try {
50
+ const res = await fetchFn(url);
51
+ if (!res.ok) return null;
52
+ return (await res.json()) as OAuthServerMetadata;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ // ============================================
59
+ // Dynamic Client Registration
60
+ // ============================================
61
+
62
+ /**
63
+ * Dynamically register a client with an OAuth server.
64
+ * RFC 7591 — used when the MCP server supports dynamic registration.
65
+ */
66
+ export async function dynamicClientRegistration(
67
+ registrationEndpoint: string,
68
+ params: {
69
+ clientName: string;
70
+ redirectUris?: string[];
71
+ grantTypes?: string[];
72
+ tokenEndpointAuthMethod?: string;
73
+ },
74
+ fetchFn: typeof globalThis.fetch = globalThis.fetch,
75
+ ): Promise<{ clientId: string; clientSecret?: string }> {
76
+ const res = await fetchFn(registrationEndpoint, {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify({
80
+ client_name: params.clientName,
81
+ redirect_uris: params.redirectUris,
82
+ grant_types: params.grantTypes ?? ["authorization_code"],
83
+ token_endpoint_auth_method:
84
+ params.tokenEndpointAuthMethod ?? "none",
85
+ }),
86
+ });
87
+ if (!res.ok) {
88
+ const text = await res.text().catch(() => "unknown");
89
+ throw new Error(
90
+ `Dynamic client registration failed: ${res.status} ${text}`,
91
+ );
92
+ }
93
+ const data = (await res.json()) as Record<string, unknown>;
94
+ return {
95
+ clientId: data.client_id as string,
96
+ clientSecret: data.client_secret as string | undefined,
97
+ };
98
+ }
99
+
100
+ // ============================================
101
+ // Authorization URL
102
+ // ============================================
103
+
104
+ /**
105
+ * Build an OAuth authorization URL with PKCE.
106
+ * Returns the URL + the code_verifier (to be stored server-side).
107
+ */
108
+ export async function buildOAuthAuthorizeUrl(params: {
109
+ authorizationEndpoint: string;
110
+ clientId: string;
111
+ redirectUri: string;
112
+ scopes?: string[];
113
+ state?: string;
114
+ }): Promise<{
115
+ url: string;
116
+ codeVerifier: string;
117
+ }> {
118
+ const pkce = await generatePkcePair();
119
+ const url = new URL(params.authorizationEndpoint);
120
+ url.searchParams.set("response_type", "code");
121
+ url.searchParams.set("client_id", params.clientId);
122
+ url.searchParams.set("redirect_uri", params.redirectUri);
123
+ url.searchParams.set("code_challenge", pkce.codeChallenge);
124
+ url.searchParams.set("code_challenge_method", pkce.codeChallengeMethod);
125
+ if (params.scopes?.length) {
126
+ url.searchParams.set("scope", params.scopes.join(" "));
127
+ }
128
+ if (params.state) {
129
+ url.searchParams.set("state", params.state);
130
+ }
131
+ return { url: url.toString(), codeVerifier: pkce.codeVerifier };
132
+ }
133
+
134
+ // ============================================
135
+ // Token Exchange
136
+ // ============================================
137
+
138
+ /**
139
+ * Exchange an authorization code for tokens (with PKCE).
140
+ */
141
+ export async function exchangeCodeForTokens(
142
+ tokenEndpoint: string,
143
+ params: {
144
+ code: string;
145
+ codeVerifier: string;
146
+ clientId: string;
147
+ clientSecret?: string;
148
+ redirectUri: string;
149
+ },
150
+ fetchFn: typeof globalThis.fetch = globalThis.fetch,
151
+ ): Promise<{
152
+ accessToken: string;
153
+ refreshToken?: string;
154
+ expiresIn?: number;
155
+ tokenType?: string;
156
+ }> {
157
+ const body = new URLSearchParams({
158
+ grant_type: "authorization_code",
159
+ code: params.code,
160
+ code_verifier: params.codeVerifier,
161
+ client_id: params.clientId,
162
+ redirect_uri: params.redirectUri,
163
+ });
164
+ if (params.clientSecret) {
165
+ body.set("client_secret", params.clientSecret);
166
+ }
167
+
168
+ const res = await fetchFn(tokenEndpoint, {
169
+ method: "POST",
170
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
171
+ body: body.toString(),
172
+ });
173
+ if (!res.ok) {
174
+ const text = await res.text().catch(() => "unknown");
175
+ throw new Error(`Token exchange failed: ${res.status} ${text}`);
176
+ }
177
+ const data = (await res.json()) as Record<string, unknown>;
178
+ return {
179
+ accessToken: data.access_token as string,
180
+ refreshToken: data.refresh_token as string | undefined,
181
+ expiresIn: data.expires_in as number | undefined,
182
+ tokenType: data.token_type as string | undefined,
183
+ };
184
+ }
185
+
186
+ // ============================================
187
+ // Token Refresh
188
+ // ============================================
189
+
190
+ /**
191
+ * Refresh an access token.
192
+ */
193
+ export async function refreshAccessToken(
194
+ tokenEndpoint: string,
195
+ params: {
196
+ refreshToken: string;
197
+ clientId: string;
198
+ clientSecret?: string;
199
+ },
200
+ fetchFn: typeof globalThis.fetch = globalThis.fetch,
201
+ ): Promise<{
202
+ accessToken: string;
203
+ refreshToken?: string;
204
+ expiresIn?: number;
205
+ }> {
206
+ const body = new URLSearchParams({
207
+ grant_type: "refresh_token",
208
+ refresh_token: params.refreshToken,
209
+ client_id: params.clientId,
210
+ });
211
+ if (params.clientSecret) {
212
+ body.set("client_secret", params.clientSecret);
213
+ }
214
+
215
+ const res = await fetchFn(tokenEndpoint, {
216
+ method: "POST",
217
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
218
+ body: body.toString(),
219
+ });
220
+ if (!res.ok) {
221
+ const text = await res.text().catch(() => "unknown");
222
+ throw new Error(`Token refresh failed: ${res.status} ${text}`);
223
+ }
224
+ const data = (await res.json()) as Record<string, unknown>;
225
+ return {
226
+ accessToken: data.access_token as string,
227
+ refreshToken: data.refresh_token as string | undefined,
228
+ expiresIn: data.expires_in as number | undefined,
229
+ };
230
+ }