@topolo/sdk 0.1.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.
package/src/errors.ts ADDED
@@ -0,0 +1,39 @@
1
+ export class TopoloSdkError extends Error {
2
+ readonly code: string;
3
+ constructor(code: string, message: string) {
4
+ super(message);
5
+ this.name = 'TopoloSdkError';
6
+ this.code = code;
7
+ }
8
+ }
9
+
10
+ export class TopoloAuthError extends TopoloSdkError {
11
+ constructor(message: string, code: string = 'auth_error') {
12
+ super(code, message);
13
+ this.name = 'TopoloAuthError';
14
+ }
15
+ }
16
+
17
+ export class TopoloPermissionError extends TopoloSdkError {
18
+ readonly required: string[];
19
+ constructor(message: string, required: string[]) {
20
+ super('permission_denied', message);
21
+ this.name = 'TopoloPermissionError';
22
+ this.required = required;
23
+ }
24
+ }
25
+
26
+ export class TopoloHttpError extends TopoloSdkError {
27
+ readonly status: number;
28
+ readonly body: unknown;
29
+ readonly service: string;
30
+ readonly path: string;
31
+ constructor(service: string, path: string, status: number, body: unknown, message?: string) {
32
+ super('http_error', message ?? `HTTP ${status} from ${service}${path}`);
33
+ this.name = 'TopoloHttpError';
34
+ this.status = status;
35
+ this.body = body;
36
+ this.service = service;
37
+ this.path = path;
38
+ }
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ export {
2
+ TopoloClient,
3
+ type TopoloClientOptions,
4
+ type RequestOptions,
5
+ type CredentialIntrospection,
6
+ } from './client.js';
7
+ export type { TopoloCredential, AgentIdentity } from './auth.js';
8
+ export {
9
+ DEFAULT_SERVICE_URLS,
10
+ PLATFORM_SERVICE_IDS,
11
+ resolveServiceUrl,
12
+ type ServiceId,
13
+ } from './services.js';
14
+ export {
15
+ TopoloSdkError,
16
+ TopoloAuthError,
17
+ TopoloPermissionError,
18
+ TopoloHttpError,
19
+ } from './errors.js';
20
+ export { CrmModule, type CrmContactSummary, type ListContactsOptions, type ListContactsResult } from './modules/crm.js';
21
+ export { IdentityModule } from './modules/identity.js';
22
+ export {
23
+ TopoloOAuth,
24
+ type OAuthHelperOptions,
25
+ type DeviceAuthorizationResponse,
26
+ type TokenResponse,
27
+ } from './oauth.js';
28
+
29
+ import { TopoloClient, type TopoloClientOptions } from './client.js';
30
+ import { CrmModule } from './modules/crm.js';
31
+ import { IdentityModule } from './modules/identity.js';
32
+
33
+ /**
34
+ * Convenience factory that returns a ready-to-use typed Topolo client plus
35
+ * domain module surfaces (identity, crm, ...).
36
+ *
37
+ * Phase 1 exposes identity + crm; subsequent phases add more modules
38
+ * incrementally as each app's API contract stabilizes.
39
+ */
40
+ export function createTopolo(options: TopoloClientOptions) {
41
+ const client = new TopoloClient(options);
42
+ return {
43
+ client,
44
+ identity: new IdentityModule(client),
45
+ crm: new CrmModule(client),
46
+ };
47
+ }
48
+
49
+ export type Topolo = ReturnType<typeof createTopolo>;
@@ -0,0 +1,110 @@
1
+ import type { TopoloClient } from '../client.js';
2
+
3
+ export interface CrmContactSummary {
4
+ id: string;
5
+ firstName: string | null;
6
+ lastName: string | null;
7
+ email: string | null;
8
+ phone: string | null;
9
+ company: string | null;
10
+ leadStatus: string | null;
11
+ lifecycleStage: string | null;
12
+ updatedAt: string | null;
13
+ }
14
+
15
+ export interface ListContactsOptions {
16
+ /** Full-text search string. */
17
+ q?: string;
18
+ /** Page number, 1-indexed. */
19
+ page?: number;
20
+ /** Page size. */
21
+ pageSize?: number;
22
+ }
23
+
24
+ export interface ListContactsResult {
25
+ contacts: CrmContactSummary[];
26
+ total: number;
27
+ page: number;
28
+ pageSize: number;
29
+ }
30
+
31
+ /**
32
+ * CRM module — thin typed wrapper over the TopoloCRM HTTP API.
33
+ *
34
+ * Cross-org isolation: there is intentionally NO `orgId` parameter anywhere in
35
+ * this module. The organization is derived entirely from the auth credential
36
+ * by the CRM backend, which scopes all SQL queries by `org_slug`/`org_id`.
37
+ */
38
+ export class CrmModule {
39
+ constructor(private readonly client: TopoloClient) {}
40
+
41
+ async listContacts(options: ListContactsOptions = {}): Promise<ListContactsResult> {
42
+ const res = await this.client.request<RawListResponse>({
43
+ service: 'crm',
44
+ path: '/api/contacts',
45
+ query: {
46
+ q: options.q,
47
+ page: options.page,
48
+ pageSize: options.pageSize,
49
+ },
50
+ });
51
+
52
+ const rows = Array.isArray(res?.contacts)
53
+ ? res.contacts
54
+ : Array.isArray(res?.data)
55
+ ? res.data
56
+ : [];
57
+
58
+ return {
59
+ contacts: rows.map(toSummary),
60
+ total: res?.total ?? res?.pagination?.total ?? rows.length,
61
+ page: res?.page ?? res?.pagination?.page ?? options.page ?? 1,
62
+ pageSize: res?.pageSize ?? res?.pagination?.pageSize ?? options.pageSize ?? rows.length,
63
+ };
64
+ }
65
+
66
+ async getContact(contactId: string): Promise<CrmContactSummary | null> {
67
+ if (!contactId) throw new Error('contactId is required');
68
+ const res = await this.client.request<{ data?: RawContact } | RawContact>({
69
+ service: 'crm',
70
+ path: `/api/contacts/${encodeURIComponent(contactId)}`,
71
+ });
72
+ const row = (res as { data?: RawContact }).data ?? (res as RawContact);
73
+ return row ? toSummary(row) : null;
74
+ }
75
+ }
76
+
77
+ interface RawContact {
78
+ id: string;
79
+ first?: string | null;
80
+ last?: string | null;
81
+ email?: string | null;
82
+ phone?: string | null;
83
+ company?: string | null;
84
+ lead_status?: string | null;
85
+ lifecycle_stage?: string | null;
86
+ updated_at?: string | null;
87
+ }
88
+
89
+ interface RawListResponse {
90
+ contacts?: RawContact[];
91
+ data?: RawContact[];
92
+ total?: number;
93
+ page?: number;
94
+ pageSize?: number;
95
+ pagination?: { total?: number; page?: number; pageSize?: number };
96
+ }
97
+
98
+ function toSummary(row: RawContact): CrmContactSummary {
99
+ return {
100
+ id: row.id,
101
+ firstName: row.first ?? null,
102
+ lastName: row.last ?? null,
103
+ email: row.email ?? null,
104
+ phone: row.phone ?? null,
105
+ company: row.company ?? null,
106
+ leadStatus: row.lead_status ?? null,
107
+ lifecycleStage: row.lifecycle_stage ?? null,
108
+ updatedAt: row.updated_at ?? null,
109
+ };
110
+ }
@@ -0,0 +1,18 @@
1
+ import type { CredentialIntrospection, TopoloClient } from '../client.js';
2
+
3
+ /**
4
+ * Identity module — wraps TopoloAuth's caller-identity endpoints.
5
+ *
6
+ * All responses are scoped to the organization the credential was issued for;
7
+ * there is no way to ask this module about a different org.
8
+ */
9
+ export class IdentityModule {
10
+ constructor(private readonly client: TopoloClient) {}
11
+
12
+ /** Returns the resolved user, organization, and permission set for the
13
+ * current credential. Equivalent to `TopoloClient.introspect()` — exposed
14
+ * on this module for symmetry with the other domain modules. */
15
+ whoami(): Promise<CredentialIntrospection> {
16
+ return this.client.introspect();
17
+ }
18
+ }
package/src/oauth.ts ADDED
@@ -0,0 +1,138 @@
1
+ import { TopoloAuthError, TopoloHttpError } from './errors.js';
2
+ import { resolveServiceUrl, type ServiceId } from './services.js';
3
+
4
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
5
+
6
+ export interface DeviceAuthorizationResponse {
7
+ device_code: string;
8
+ user_code: string;
9
+ verification_uri: string;
10
+ verification_uri_complete?: string;
11
+ expires_in: number;
12
+ interval: number;
13
+ }
14
+
15
+ export interface TokenResponse {
16
+ access_token: string;
17
+ token_type: 'Bearer';
18
+ expires_in: number;
19
+ refresh_token?: string;
20
+ scope?: string;
21
+ }
22
+
23
+ export interface OAuthHelperOptions {
24
+ /** Overrides for TopoloAuth URL. Staging/dev should pass a mapping. */
25
+ serviceUrls?: Partial<Record<ServiceId, string>>;
26
+ /** Injected fetch. Defaults to global fetch. */
27
+ fetch?: typeof fetch;
28
+ }
29
+
30
+ /**
31
+ * Thin client over the Topolo OAuth 2.1 authorization server. Intended for
32
+ * first-party tooling (the CLI) and registered third-party agents. Issued
33
+ * access tokens plug into `createTopolo({ credential: { kind: 'access_token', ... } })`.
34
+ */
35
+ export class TopoloOAuth {
36
+ private readonly baseUrl: string;
37
+ private readonly fetchImpl: typeof fetch;
38
+
39
+ constructor(options: OAuthHelperOptions = {}) {
40
+ this.baseUrl = resolveServiceUrl('auth', options.serviceUrls);
41
+ this.fetchImpl = options.fetch ?? fetch;
42
+ }
43
+
44
+ /**
45
+ * RFC 8628 device authorization grant — starts the flow by asking the server
46
+ * for a device_code + user_code. The caller prints the user_code +
47
+ * verification_uri and then polls `pollDeviceToken` until approval.
48
+ */
49
+ async requestDeviceCode(params: {
50
+ clientId: string;
51
+ scope?: string | string[];
52
+ }): Promise<DeviceAuthorizationResponse> {
53
+ const body = new URLSearchParams();
54
+ body.set('client_id', params.clientId);
55
+ if (params.scope) {
56
+ body.set('scope', Array.isArray(params.scope) ? params.scope.join(' ') : params.scope);
57
+ }
58
+ return this.#postForm('/api/developer-oauth/device_authorization', body);
59
+ }
60
+
61
+ /**
62
+ * Polls the token endpoint once. Returns the token pair on success, or
63
+ * throws a `TopoloAuthError` whose `code` is one of the RFC 8628 poll
64
+ * states: `authorization_pending`, `slow_down`, `access_denied`,
65
+ * `expired_token`. Callers should back off on `slow_down`, wait at least
66
+ * one `interval` on `authorization_pending`, and give up on the rest.
67
+ */
68
+ async pollDeviceToken(params: {
69
+ clientId: string;
70
+ deviceCode: string;
71
+ clientSecret?: string;
72
+ }): Promise<TokenResponse> {
73
+ const body = new URLSearchParams();
74
+ body.set('grant_type', DEVICE_GRANT);
75
+ body.set('client_id', params.clientId);
76
+ body.set('device_code', params.deviceCode);
77
+ if (params.clientSecret) body.set('client_secret', params.clientSecret);
78
+ return this.#postForm('/api/developer-oauth/token', body);
79
+ }
80
+
81
+ /** Exchange an authorization code (with PKCE) for tokens. */
82
+ async exchangeAuthorizationCode(params: {
83
+ clientId: string;
84
+ code: string;
85
+ redirectUri: string;
86
+ codeVerifier?: string;
87
+ clientSecret?: string;
88
+ }): Promise<TokenResponse> {
89
+ const body = new URLSearchParams();
90
+ body.set('grant_type', 'authorization_code');
91
+ body.set('client_id', params.clientId);
92
+ body.set('code', params.code);
93
+ body.set('redirect_uri', params.redirectUri);
94
+ if (params.codeVerifier) body.set('code_verifier', params.codeVerifier);
95
+ if (params.clientSecret) body.set('client_secret', params.clientSecret);
96
+ return this.#postForm('/api/developer-oauth/token', body);
97
+ }
98
+
99
+ /** Rotate a refresh token for a new token pair. */
100
+ async refreshToken(params: {
101
+ clientId: string;
102
+ refreshToken: string;
103
+ clientSecret?: string;
104
+ }): Promise<TokenResponse> {
105
+ const body = new URLSearchParams();
106
+ body.set('grant_type', 'refresh_token');
107
+ body.set('client_id', params.clientId);
108
+ body.set('refresh_token', params.refreshToken);
109
+ if (params.clientSecret) body.set('client_secret', params.clientSecret);
110
+ return this.#postForm('/api/developer-oauth/token', body);
111
+ }
112
+
113
+ async #postForm<T>(path: string, body: URLSearchParams): Promise<T> {
114
+ const url = new URL(path, this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`);
115
+ const res = await this.fetchImpl(url.toString(), {
116
+ method: 'POST',
117
+ headers: {
118
+ 'Content-Type': 'application/x-www-form-urlencoded',
119
+ Accept: 'application/json',
120
+ },
121
+ body: body.toString(),
122
+ });
123
+
124
+ const contentType = res.headers.get('Content-Type') ?? '';
125
+ const parsed: unknown = contentType.includes('application/json')
126
+ ? await res.json().catch(() => null)
127
+ : await res.text().catch(() => null);
128
+
129
+ if (!res.ok) {
130
+ if (parsed && typeof parsed === 'object' && 'error' in parsed) {
131
+ const err = parsed as { error: string; error_description?: string };
132
+ throw new TopoloAuthError(err.error_description || err.error, err.error);
133
+ }
134
+ throw new TopoloHttpError('auth', path, res.status, parsed);
135
+ }
136
+ return parsed as T;
137
+ }
138
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Service registry — base URLs for each Topolo platform service.
3
+ *
4
+ * Defaults point at production. Any entry can be overridden per-call via
5
+ * `TopoloClient` options, or globally via env vars like `TOPOLO_SERVICE_URL_CRM`.
6
+ *
7
+ * IMPORTANT: The SDK NEVER accepts `orgId` as a parameter. Every request is
8
+ * scoped to the organization embedded in the auth credential (JWT claim or
9
+ * API-key binding). This is the load-bearing cross-org isolation guarantee.
10
+ */
11
+ export type ServiceId =
12
+ | 'auth'
13
+ | 'crm';
14
+
15
+ export const DEFAULT_SERVICE_URLS: Record<ServiceId, string> = {
16
+ auth: 'https://auth.topolo.app',
17
+ crm: 'https://topolo-crm-worker.topolo.workers.dev',
18
+ };
19
+
20
+ /**
21
+ * Service IDs as registered in TopoloAuth's service catalog. These are sent in
22
+ * the `X-Service-ID` header when the platform needs to route an API-key
23
+ * introspection or permission check.
24
+ */
25
+ export const PLATFORM_SERVICE_IDS: Partial<Record<ServiceId, string>> = {
26
+ crm: 'srv_iCwM4jGXcwlj',
27
+ };
28
+
29
+ export function resolveServiceUrl(
30
+ service: ServiceId,
31
+ overrides?: Partial<Record<ServiceId, string>>,
32
+ ): string {
33
+ const override = overrides?.[service];
34
+ if (override) return override;
35
+
36
+ const envKey = `TOPOLO_SERVICE_URL_${service.toUpperCase()}`;
37
+ const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
38
+ if (envValue) return envValue;
39
+
40
+ return DEFAULT_SERVICE_URLS[service];
41
+ }