Skip to content

Commit 7e6edea

Browse files
committed
feat(sdk-core): implement ConfigurableAgent for multi-server routing
Add ConfigurableAgent class that extends Agent to support routing requests to configurable service URLs, enabling proper PDS/SDS separation and multi-SDS support. Key features: - ConfigurableAgent wraps session's fetch handler to route to custom URL - Repository now uses ConfigurableAgent instead of standard Agent - Supports multiple SDS instances with same OAuth session - Maintains all authentication (DPoP, OAuth) from original session Architecture: - Agent's service URL is determined by FetchHandler, not modifiable properties - ConfigurableAgent creates custom FetchHandler that prepends target URL - Session's fetch handler provides authentication layer - Enables: user@PDS accessing orgA@SDS and orgB@SDS simultaneously Use cases: - Route to SDS while authenticated via PDS - Access multiple organization SDS instances - Test against different server environments - Switch between PDS and SDS operations dynamically Resolves the issue where invalid agent.service and agent.api.xrpc.uri property assignments were attempted. The proper solution is to wrap the fetch handler at Agent construction time. Related: #59, implements pattern from hypercerts-org/atproto sds-demo
1 parent 821ae0b commit 7e6edea

File tree

4 files changed

+239
-5
lines changed

4 files changed

+239
-5
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* ConfigurableAgent - Agent with configurable service URL routing.
3+
*
4+
* This module provides an Agent extension that allows routing requests to
5+
* a specific server URL, overriding the default URL from the OAuth session.
6+
*
7+
* @packageDocumentation
8+
*/
9+
10+
import { Agent } from "@atproto/api";
11+
import type { FetchHandler } from "@atproto/xrpc";
12+
import type { Session } from "../core/types.js";
13+
14+
/**
15+
* Agent subclass that routes requests to a configurable service URL.
16+
*
17+
* The standard Agent uses the service URL embedded in the OAuth session's
18+
* fetch handler. This class allows overriding that URL to route requests
19+
* to different servers (e.g., PDS vs SDS, or multiple SDS instances).
20+
*
21+
* @remarks
22+
* This is particularly useful for:
23+
* - Routing to a Shared Data Server (SDS) while authenticated via PDS
24+
* - Supporting multiple SDS instances for different organizations
25+
* - Testing against different server environments
26+
*
27+
* @example Basic usage
28+
* ```typescript
29+
* const session = await sdk.authorize("user.bsky.social");
30+
*
31+
* // Create agent routing to SDS instead of session's default PDS
32+
* const sdsAgent = new ConfigurableAgent(session, "https://sds.hypercerts.org");
33+
*
34+
* // All requests will now go to the SDS
35+
* await sdsAgent.com.atproto.repo.createRecord({...});
36+
* ```
37+
*
38+
* @example Multiple SDS instances
39+
* ```typescript
40+
* // Route to organization A's SDS
41+
* const orgAAgent = new ConfigurableAgent(session, "https://sds-org-a.example.com");
42+
*
43+
* // Route to organization B's SDS
44+
* const orgBAgent = new ConfigurableAgent(session, "https://sds-org-b.example.com");
45+
* ```
46+
*/
47+
export class ConfigurableAgent extends Agent {
48+
private customServiceUrl: string;
49+
50+
/**
51+
* Creates a ConfigurableAgent that routes to a specific service URL.
52+
*
53+
* @param session - OAuth session for authentication
54+
* @param serviceUrl - Base URL of the server to route requests to
55+
*
56+
* @remarks
57+
* The agent wraps the session's fetch handler to intercept requests and
58+
* prepend the custom service URL instead of using the session's default.
59+
*/
60+
constructor(session: Session, serviceUrl: string) {
61+
// Create a custom fetch handler that uses our service URL
62+
const customFetchHandler: FetchHandler = async (pathname: string, init: RequestInit) => {
63+
// Construct the full URL with our custom service
64+
const url = new URL(pathname, serviceUrl).toString();
65+
66+
// Use the session's fetch handler for authentication (DPoP, etc.)
67+
return session.fetchHandler(url, init);
68+
};
69+
70+
// Initialize the parent Agent with our custom fetch handler
71+
super(customFetchHandler);
72+
73+
this.customServiceUrl = serviceUrl;
74+
}
75+
76+
/**
77+
* Gets the service URL this agent routes to.
78+
*
79+
* @returns The base URL of the configured service
80+
*/
81+
getServiceUrl(): string {
82+
return this.customServiceUrl;
83+
}
84+
}

packages/sdk-core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export type { AuthorizeOptions } from "./core/SDK.js";
1010
export type { ATProtoSDKConfig } from "./core/config.js";
1111
export type { Session } from "./core/types.js";
1212

13+
// Agent
14+
export { ConfigurableAgent } from "./agent/ConfigurableAgent.js";
15+
1316
// Repository (fluent API)
1417
export { Repository } from "./repository/Repository.js";
1518
export type {

packages/sdk-core/src/repository/Repository.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
* @packageDocumentation
88
*/
99

10-
import { Agent } from "@atproto/api";
1110
import { SDSRequiredError } from "../core/errors.js";
1211
import type { LoggerInterface } from "../core/interfaces.js";
1312
import type { Session } from "../core/types.js";
1413
import { HYPERCERT_LEXICONS } from "@hypercerts-org/lexicon";
14+
import { ConfigurableAgent } from "../agent/ConfigurableAgent.js";
15+
import type { Agent } from "@atproto/api";
1516
import type { LexiconRegistry } from "./LexiconRegistry.js";
1617

1718
// Types
@@ -179,10 +180,10 @@ export class Repository {
179180
this._isSDS = isSDS;
180181
this.logger = logger;
181182

182-
// Create Agent with OAuth session
183-
// Note: The Agent will use the session's fetch handler which contains
184-
// the service URL configuration from the OAuth session
185-
this.agent = new Agent(session);
183+
// Create a ConfigurableAgent that routes requests to the specified server URL
184+
// This allows routing to PDS, SDS, or any custom server while maintaining
185+
// the OAuth session's authentication
186+
this.agent = new ConfigurableAgent(session, serverUrl);
186187

187188
this.lexiconRegistry.addToAgent(this.agent);
188189

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { ConfigurableAgent } from "../../src/agent/ConfigurableAgent.js";
3+
import { createMockSession } from "../utils/repository-fixtures.js";
4+
5+
describe("ConfigurableAgent", () => {
6+
let mockSession: ReturnType<typeof createMockSession>;
7+
let customServiceUrl: string;
8+
9+
beforeEach(() => {
10+
mockSession = createMockSession();
11+
customServiceUrl = "https://custom-sds.example.com";
12+
});
13+
14+
describe("constructor", () => {
15+
it("should create an agent with custom service URL", () => {
16+
const agent = new ConfigurableAgent(mockSession, customServiceUrl);
17+
18+
expect(agent).toBeDefined();
19+
expect(agent.getServiceUrl()).toBe(customServiceUrl);
20+
});
21+
22+
it("should extend Agent class", () => {
23+
const agent = new ConfigurableAgent(mockSession, customServiceUrl);
24+
25+
// Should have Agent properties and methods
26+
expect(agent.com).toBeDefined();
27+
expect(agent.app).toBeDefined();
28+
expect(typeof agent.uploadBlob).toBe("function");
29+
});
30+
});
31+
32+
describe("fetch routing", () => {
33+
it("should route requests to custom service URL", async () => {
34+
const fetchSpy = vi.fn().mockResolvedValue(
35+
new Response(JSON.stringify({ uri: "at://test/record" }), {
36+
status: 200,
37+
headers: { "Content-Type": "application/json" },
38+
}),
39+
);
40+
41+
const sessionWithSpy = createMockSession({
42+
fetchHandler: fetchSpy,
43+
});
44+
45+
const agent = new ConfigurableAgent(sessionWithSpy, customServiceUrl);
46+
47+
// Attempt to make a call (will use the mocked fetch)
48+
try {
49+
await agent.com.atproto.repo.getRecord({
50+
repo: "did:plc:test",
51+
collection: "app.bsky.feed.post",
52+
rkey: "test123",
53+
});
54+
} catch {
55+
// Expected to fail due to mock, we just care about the fetch call
56+
}
57+
58+
// Verify the fetch was called
59+
expect(fetchSpy).toHaveBeenCalled();
60+
61+
// Check that the URL passed to fetch starts with our custom service URL
62+
const callArgs = fetchSpy.mock.calls[0];
63+
const calledUrl = callArgs[0] as string;
64+
65+
// The URL should be constructed with our custom service as base
66+
expect(calledUrl).toContain(customServiceUrl);
67+
});
68+
69+
it("should work with different service URLs", () => {
70+
const pdsUrl = "https://pds.example.com";
71+
const sdsUrl = "https://sds.example.com";
72+
const customUrl = "https://custom.example.com";
73+
74+
const pdsAgent = new ConfigurableAgent(mockSession, pdsUrl);
75+
const sdsAgent = new ConfigurableAgent(mockSession, sdsUrl);
76+
const customAgent = new ConfigurableAgent(mockSession, customUrl);
77+
78+
expect(pdsAgent.getServiceUrl()).toBe(pdsUrl);
79+
expect(sdsAgent.getServiceUrl()).toBe(sdsUrl);
80+
expect(customAgent.getServiceUrl()).toBe(customUrl);
81+
});
82+
});
83+
84+
describe("authentication", () => {
85+
it("should use session's fetch handler for authentication", async () => {
86+
const authenticatedFetchSpy = vi.fn().mockResolvedValue(
87+
new Response(JSON.stringify({}), {
88+
status: 200,
89+
headers: { "Content-Type": "application/json" },
90+
}),
91+
);
92+
93+
const sessionWithAuth = createMockSession({
94+
fetchHandler: authenticatedFetchSpy,
95+
});
96+
97+
const agent = new ConfigurableAgent(sessionWithAuth, customServiceUrl);
98+
99+
try {
100+
await agent.com.atproto.repo.createRecord({
101+
repo: "did:plc:test",
102+
collection: "app.bsky.feed.post",
103+
record: { text: "test", createdAt: new Date().toISOString() },
104+
});
105+
} catch {
106+
// Expected to fail, we're checking the fetch call
107+
}
108+
109+
// Verify the session's fetch handler was used (includes auth)
110+
expect(authenticatedFetchSpy).toHaveBeenCalled();
111+
});
112+
});
113+
114+
describe("multiple instances", () => {
115+
it("should allow multiple agents with different service URLs from same session", () => {
116+
const orgA = new ConfigurableAgent(mockSession, "https://sds-org-a.example.com");
117+
const orgB = new ConfigurableAgent(mockSession, "https://sds-org-b.example.com");
118+
const pds = new ConfigurableAgent(mockSession, "https://pds.example.com");
119+
120+
expect(orgA.getServiceUrl()).toBe("https://sds-org-a.example.com");
121+
expect(orgB.getServiceUrl()).toBe("https://sds-org-b.example.com");
122+
expect(pds.getServiceUrl()).toBe("https://pds.example.com");
123+
124+
// Each agent should be independently configured
125+
expect(orgA).not.toBe(orgB);
126+
expect(orgB).not.toBe(pds);
127+
expect(orgA).not.toBe(pds);
128+
});
129+
});
130+
131+
describe("integration with Repository pattern", () => {
132+
it("should work as drop-in replacement for standard Agent", () => {
133+
const agent = new ConfigurableAgent(mockSession, customServiceUrl);
134+
135+
// Should have all the standard Agent namespaces
136+
expect(agent.com).toBeDefined();
137+
expect(agent.com.atproto).toBeDefined();
138+
expect(agent.com.atproto.repo).toBeDefined();
139+
expect(agent.app).toBeDefined();
140+
141+
// Should have utility methods
142+
expect(typeof agent.uploadBlob).toBe("function");
143+
expect(typeof agent.resolveHandle).toBe("function");
144+
});
145+
});
146+
});

0 commit comments

Comments
 (0)