@topolo/sdk 0.7.0 → 0.8.1

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/oauth.ts DELETED
@@ -1,138 +0,0 @@
1
- import { TopoloAuthError, TopoloHttpError } from './errors.js';
2
- import { resolveServiceUrl, type AppApiId } 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<AppApiId, 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
- }
@@ -1,55 +0,0 @@
1
- // AUTO-GENERATED by scripts/generate-services.mjs. Do not edit by hand.
2
- // Source of truth: topolo.cloudcontrol.json in every app repo.
3
- // Regenerate: npm run generate:services
4
-
5
- export const APP_API_URLS = {
6
- agent: "https://agent.topolo.app",
7
- auth: "https://auth.topolo.app",
8
- backup: "https://backup.topolo.app",
9
- books: "https://books.topolo.app",
10
- bugfix: "https://bugfix.topolo.app",
11
- bytes: "https://bytes.topolo.app",
12
- calendar: "https://calendar.topolo.app",
13
- capacity: "https://capacity.topolo.app",
14
- chat: "https://chat.topolo.app",
15
- commerce: "https://commerce.topolo.app",
16
- compose: "https://compose.topolo.app",
17
- consent: "https://consent.topolo.app",
18
- crm: "https://crm.topolo.app",
19
- developers: "https://developers.topolo.app",
20
- director: "https://director.topolo.app",
21
- feed: "https://feed.topolo.app",
22
- flow: "https://flow.topolo.app",
23
- forecast: "https://forecast.topolo.app",
24
- forms: "https://forms.topolo.app",
25
- home: "https://home.topolo.app",
26
- insights: "https://insights.topolo.app",
27
- inventory: "https://inventory.topolo.app",
28
- learn: "https://learn.topolo.app",
29
- localize: "https://localize.topolo.app",
30
- mail: "https://mail.topolo.app",
31
- mdm: "https://mdm.topolo.app",
32
- messages: "https://messages.topolo.app",
33
- nexus: "https://nexus.topolo.app",
34
- notify: "https://notify.topolo.app",
35
- observability_watch: "https://observability-watch.topolo.app",
36
- one: "https://www.topolo.app",
37
- p2p: "https://p2p.topolo.app",
38
- pay: "https://pay.topolo.app",
39
- people: "https://people.topolo.app",
40
- "quro.api": "https://api.ol0.me",
41
- "quro.redirect": "https://ol0.me",
42
- roadmapper: "https://roadmapper.topolo.app",
43
- sign: "https://sign.topolo.app",
44
- social_studio: "https://studio.topolo.app",
45
- socialize: "https://socialize.topolo.app",
46
- spaces: "https://spaces.topolo.app",
47
- status: "https://status.topolo.app",
48
- success: "https://success.topolo.app",
49
- support: "https://support.topolo.app",
50
- survey: "https://survey.topolo.app",
51
- voice: "https://voice.topolo.app",
52
- web: "https://web.topolo.app",
53
- } as const;
54
-
55
- export type AppApiId = keyof typeof APP_API_URLS;
@@ -1,243 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- APPLICATION_REQUIREMENTS,
4
- APPLICATION_REQUIREMENTS_VERSION,
5
- APPLICATIONS,
6
- DEFAULT_APP_URLS,
7
- auditAllApplicationRequirements,
8
- auditApplicationRequirements,
9
- applicationRequirementScopes,
10
- resolveCatalogServiceUrl,
11
- requirementsForApplication,
12
- } from './services.js';
13
-
14
- const EXPECTED_APPLICATION_IDS = [
15
- 'admin',
16
- 'agent',
17
- 'auth',
18
- 'backup',
19
- 'books',
20
- 'bugfix',
21
- 'bytes',
22
- 'calendar',
23
- 'capacity',
24
- 'chat',
25
- 'commerce',
26
- 'compose',
27
- 'consent',
28
- 'crm',
29
- 'developers',
30
- 'director',
31
- 'docs',
32
- 'feed',
33
- 'flow',
34
- 'forecast',
35
- 'forms',
36
- 'home',
37
- 'insights',
38
- 'inventory',
39
- 'learn',
40
- 'localize',
41
- 'mail',
42
- 'mdm',
43
- 'messages',
44
- 'nexus',
45
- 'notify',
46
- 'observability_watch',
47
- 'one',
48
- 'p2p',
49
- 'pay',
50
- 'people',
51
- 'quro',
52
- 'roadmapper',
53
- 'sign',
54
- 'social_studio',
55
- 'socialize',
56
- 'spaces',
57
- 'status',
58
- 'success',
59
- 'support',
60
- 'survey',
61
- 'voice',
62
- 'web',
63
- ];
64
-
65
- describe('app API registry', () => {
66
- it('includes every production worker API that belongs to a platform application', () => {
67
- expect(DEFAULT_APP_URLS).toMatchObject({
68
- calendar: 'https://calendar.topolo.app',
69
- books: 'https://books.topolo.app',
70
- consent: 'https://consent.topolo.app',
71
- feed: 'https://feed.topolo.app',
72
- mail: 'https://mail.topolo.app',
73
- one: 'https://www.topolo.app',
74
- web: 'https://web.topolo.app',
75
- });
76
- expect(DEFAULT_APP_URLS).toMatchObject({
77
- success: 'https://success.topolo.app',
78
- forms: 'https://forms.topolo.app',
79
- survey: 'https://survey.topolo.app',
80
- });
81
- expect(DEFAULT_APP_URLS).not.toHaveProperty('web.api');
82
- expect(DEFAULT_APP_URLS).not.toHaveProperty('web.runtime');
83
- });
84
-
85
- it('does not bake opaque Auth app ids into the generated registry', async () => {
86
- const services = await import('./services.js');
87
-
88
- expect(services).not.toHaveProperty('PLATFORM_APP_IDS');
89
- });
90
-
91
- it('maps every callable app API back to an application catalog entry', () => {
92
- const applicationServices = new Set<string>(
93
- Object.values(APPLICATIONS).flatMap((application) => [...application.services]),
94
- );
95
-
96
- for (const appId of Object.keys(DEFAULT_APP_URLS)) {
97
- expect(applicationServices.has(appId)).toBe(true);
98
- }
99
- });
100
-
101
- it('resolves services from explicit catalog API URLs', () => {
102
- expect(resolveCatalogServiceUrl({
103
- appId: 'app_crm',
104
- slug: 'topolo-crm',
105
- name: 'Topolo CRM',
106
- launchUrl: 'https://crm.topolo.app',
107
- apiBaseUrl: 'https://crm-api.topolo.app',
108
- }, { serviceKey: 'crm' })).toBe('https://crm-api.topolo.app');
109
- });
110
-
111
- it('does not infer callable URLs from launcher URLs', () => {
112
- expect(resolveCatalogServiceUrl({
113
- appId: 'app_crm',
114
- slug: 'topolo-crm',
115
- name: 'Topolo CRM',
116
- launchUrl: 'https://crm.stg.topolo.us',
117
- }, { serviceKey: 'crm' })).toBeNull();
118
- });
119
-
120
- it('prefers explicit catalog API URLs over launch URLs', () => {
121
- expect(resolveCatalogServiceUrl({
122
- appId: 'app_future',
123
- slug: 'topolo-future',
124
- name: 'Future',
125
- launchUrl: 'https://future.topolo.app',
126
- apiBaseUrl: 'https://future-api.topolo.app',
127
- })).toBe('https://future-api.topolo.app');
128
- });
129
- });
130
-
131
- describe('application catalog', () => {
132
- it('covers every production-routable Topolo application directory', () => {
133
- expect(Object.keys(APPLICATIONS).sort()).toEqual(EXPECTED_APPLICATION_IDS);
134
- });
135
-
136
- it('only references registered app API ids from application metadata', () => {
137
- const registeredServices = new Set<string>(Object.keys(DEFAULT_APP_URLS));
138
-
139
- for (const application of Object.values(APPLICATIONS)) {
140
- for (const appId of application.services) {
141
- expect(registeredServices.has(appId)).toBe(true);
142
- }
143
- for (const target of application.deployTargets) {
144
- if (target.appId) expect(registeredServices.has(target.appId)).toBe(true);
145
- }
146
- }
147
- });
148
-
149
- it('treats TopoloWeb runtime as a standalone deploy, not an agent-callable API service', () => {
150
- expect(APPLICATIONS.web.services).toEqual(['web']);
151
- // Front-door standardization: the callable `web` service is served by the
152
- // studio web worker (web.topolo.app/api); the API worker is de-routed and
153
- // no longer carries a public service registration.
154
- expect(
155
- APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-studio')?.appId,
156
- ).toBe('web');
157
- expect(
158
- APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-api')?.appId,
159
- ).toBeNull();
160
- // topolo-web-runtime (Astro, sites.topolo.app) deploys from its own wrangler
161
- // config and is intentionally listed as non-callable, so it can never be an
162
- // agent-callable app API.
163
- expect(
164
- APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-runtime')?.appId,
165
- ).toBeNull();
166
- const callableTargetNames = APPLICATIONS.web.deployTargets
167
- .filter((target) => target.appId)
168
- .map((target) => target.name);
169
- expect(callableTargetNames).not.toContain('topolo-web-runtime');
170
- });
171
-
172
- it('treats TopoloFeed analytics as telemetry, not an agent-callable API service', () => {
173
- expect(APPLICATIONS.feed.services).toEqual(['feed']);
174
- // Front-door standardization: the callable `feed` service is served by the
175
- // feed web worker (feed.topolo.app/api); the API worker is de-routed.
176
- expect(
177
- APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-web')?.appId,
178
- ).toBe('feed');
179
- expect(
180
- APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-api')?.appId,
181
- ).toBeNull();
182
- // The analytics ingestion worker keeps its own host and is non-callable.
183
- expect(
184
- APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-analytics-api')?.appId,
185
- ).toBeNull();
186
- });
187
- });
188
-
189
- describe('application requirements', () => {
190
- it('exposes a stable versioned requirement list', () => {
191
- expect(APPLICATION_REQUIREMENTS_VERSION).toMatch(/^\d{4}-\d{2}-\d{2}\.\d+$/);
192
- expect(APPLICATION_REQUIREMENTS.length).toBeGreaterThan(5);
193
- expect(APPLICATION_REQUIREMENTS.map((requirement) => requirement.id)).toContain(
194
- 'shared-auth-boundary',
195
- );
196
- expect(APPLICATION_REQUIREMENTS.map((requirement) => requirement.id)).toContain(
197
- 'native-dashboard-widget',
198
- );
199
- });
200
-
201
- it('derives browser and API scopes from app metadata', () => {
202
- expect(applicationRequirementScopes(APPLICATIONS.commerce)).toEqual(
203
- expect.arrayContaining(['all', 'browser', 'api', 'agent_surface']),
204
- );
205
- expect(applicationRequirementScopes(APPLICATIONS.mail)).toEqual(
206
- expect.arrayContaining(['all', 'browser', 'api', 'agent_surface']),
207
- );
208
- });
209
-
210
- it('filters requirements for a target application', () => {
211
- const adminRequirementIds = requirementsForApplication('admin').map((requirement) => requirement.id);
212
- const mailRequirementIds = requirementsForApplication('mail').map((requirement) => requirement.id);
213
-
214
- expect(adminRequirementIds).toContain('shared-shell-and-launcher');
215
- expect(adminRequirementIds).not.toContain('organization-scoped-data');
216
- expect(mailRequirementIds).toContain('organization-scoped-data');
217
- expect(mailRequirementIds).toContain('app-registration-and-scopes');
218
- expect(mailRequirementIds).toContain('native-dashboard-widget');
219
- });
220
-
221
- it('audits one application against the catalog-backed requirements', () => {
222
- const audit = auditApplicationRequirements('mail');
223
- const byRequirement = new Map(audit.findings.map((finding) => [finding.requirementId, finding]));
224
-
225
- expect(audit.version).toBe(APPLICATION_REQUIREMENTS_VERSION);
226
- expect(audit.application.id).toBe('mail');
227
- expect(audit.score.total).toBe(audit.findings.length);
228
- expect(byRequirement.get('platform-metadata')?.status).toBe('met');
229
- expect(byRequirement.get('app-registration-and-scopes')?.status).toBe('partial');
230
- expect(byRequirement.get('canonical-docs')?.status).toBe('needs_review');
231
- });
232
-
233
- it('builds a sorted migration queue for all applications', () => {
234
- const report = auditAllApplicationRequirements(['mail', 'admin']);
235
- expect(report.applications).toHaveLength(2);
236
- expect(report.migrationQueue.length).toBeGreaterThan(0);
237
- expect(report.migrationQueue[0]).toMatchObject({
238
- applicationId: expect.any(String),
239
- requirementId: expect.any(String),
240
- status: expect.stringMatching(/missing|partial|needs_review/),
241
- });
242
- });
243
- });
package/src/services.ts DELETED
@@ -1,138 +0,0 @@
1
- /**
2
- * App API registry — base URLs for callable Topolo platform APIs.
3
- *
4
- * Source of truth lives in each app's `topolo.cloudcontrol.json`. The
5
- * `APP_API_URLS` map is regenerated from those files via
6
- * `scripts/generate-services.mjs`; edit the JSON, not the generated file.
7
- *
8
- * Any entry can be overridden per-call via `TopoloClient` options, or globally
9
- * via env vars like `TOPOLO_APP_URL_MAIL` or `TOPOLO_APP_URL_WEB_API`
10
- * (dots in app API keys become underscores in env vars).
11
- *
12
- * IMPORTANT: The SDK NEVER accepts `orgId` as a parameter. Every request is
13
- * scoped to the organization embedded in the auth credential (JWT claim or
14
- * API-key binding). This is the load-bearing cross-org isolation guarantee.
15
- */
16
- import {
17
- APP_API_URLS,
18
- type AppApiId,
19
- } from './services.generated.js';
20
- import {
21
- APPLICATIONS,
22
- type ApplicationCatalogEntry,
23
- type ApplicationDeployTarget,
24
- type ApplicationId,
25
- } from './applications.generated.js';
26
- export {
27
- APPLICATION_REQUIREMENTS,
28
- APPLICATION_REQUIREMENTS_VERSION,
29
- auditAllApplicationRequirements,
30
- auditApplicationRequirements,
31
- applicationRequirementScopes,
32
- requirementsForApplication,
33
- type ApplicationRequirement,
34
- type ApplicationRequirementAudit,
35
- type ApplicationRequirementFinding,
36
- type ApplicationRequirementMigrationItem,
37
- type ApplicationRequirementScore,
38
- type ApplicationRequirementScope,
39
- type ApplicationRequirementStatus,
40
- type ApplicationRequirementsAuditReport,
41
- } from './application-requirements.js';
42
-
43
- export { APP_API_URLS };
44
- export type { AppApiId };
45
- export { APPLICATIONS };
46
- export type { ApplicationCatalogEntry, ApplicationDeployTarget, ApplicationId };
47
-
48
- export const DEFAULT_APP_URLS: Record<AppApiId, string> = APP_API_URLS;
49
-
50
- export interface ServiceCatalogUrlEntry {
51
- appId?: string | null;
52
- slug?: string | null;
53
- name?: string | null;
54
- apiBaseUrl?: string | null;
55
- launchUrl?: string | null;
56
- api_base_url?: string | null;
57
- launch_url?: string | null;
58
- }
59
-
60
- export function resolveCatalogServiceUrl(
61
- entry: ServiceCatalogUrlEntry,
62
- options: {
63
- serviceKey?: string;
64
- overrides?: Record<string, string> | Partial<Record<AppApiId, string>>;
65
- } = {},
66
- ): string | null {
67
- const keys = catalogServiceKeyCandidates(entry, options.serviceKey);
68
- const override = resolveServiceUrlOverride(keys, options.overrides);
69
- if (override) return override;
70
-
71
- const apiBaseUrl = firstString(entry.apiBaseUrl, entry.api_base_url);
72
- if (apiBaseUrl) return apiBaseUrl;
73
-
74
- return null;
75
- }
76
-
77
- export function resolveServiceUrl(
78
- service: AppApiId,
79
- overrides?: Partial<Record<AppApiId, string>>,
80
- ): string {
81
- const override = resolveServiceUrlOverride([service], overrides);
82
- if (override) return override;
83
-
84
- return DEFAULT_APP_URLS[service];
85
- }
86
-
87
- function catalogServiceKeyCandidates(entry: ServiceCatalogUrlEntry, preferred?: string): string[] {
88
- const values = [preferred, entry.slug, entry.name, entry.appId].filter(
89
- (value): value is string => typeof value === 'string' && value.trim().length > 0,
90
- );
91
- const keys = new Set<string>();
92
- for (const value of values) {
93
- for (const candidate of serviceKeyCandidates(value)) keys.add(candidate);
94
- }
95
- return [...keys];
96
- }
97
-
98
- function serviceKeyCandidates(value: string): string[] {
99
- const normalized = value.trim().toLowerCase().replace(/\s+/g, '-');
100
- const withoutTopolo = normalized.startsWith('topolo-')
101
- ? normalized.slice('topolo-'.length)
102
- : normalized;
103
- return [...new Set([
104
- normalized,
105
- normalized.replace(/-/g, '_'),
106
- withoutTopolo,
107
- withoutTopolo.replace(/-/g, '_'),
108
- ])].filter(Boolean);
109
- }
110
-
111
- function resolveServiceUrlOverride(
112
- keys: string[],
113
- overrides?: Record<string, string> | Partial<Record<AppApiId, string>>,
114
- ): string | null {
115
- for (const key of keys) {
116
- const direct = overrides?.[key as AppApiId];
117
- if (direct) return direct;
118
-
119
- const envStyleKey = key.replace(/\./g, '_');
120
- const envStyle = overrides?.[envStyleKey as AppApiId];
121
- if (envStyle) return envStyle;
122
- }
123
-
124
- for (const key of keys) {
125
- const envKey = `TOPOLO_APP_URL_${key.replace(/\./g, '_').toUpperCase()}`;
126
- const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
127
- if (envValue) return envValue;
128
- }
129
-
130
- return null;
131
- }
132
-
133
- function firstString(...values: Array<string | null | undefined>): string | null {
134
- for (const value of values) {
135
- if (typeof value === 'string' && value.trim()) return value.trim();
136
- }
137
- return null;
138
- }