@topolo/sdk 0.4.10 → 0.6.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/README.md +4 -4
- package/dist/index.cjs +160 -158
- package/dist/index.d.cts +136 -138
- package/dist/index.d.ts +136 -138
- package/dist/index.js +159 -157
- package/dist/node/credential-store.cjs +440 -0
- package/dist/node/credential-store.d.cts +81 -0
- package/dist/node/credential-store.d.ts +81 -0
- package/dist/node/credential-store.js +399 -0
- package/package.json +7 -2
- package/src/application-requirements.ts +16 -16
- package/src/applications.generated.ts +98 -98
- package/src/client.test.ts +56 -56
- package/src/client.ts +48 -48
- package/src/index.test.ts +3 -3
- package/src/index.ts +2 -2
- package/src/modules/crm.test.ts +4 -4
- package/src/modules/identity.test.ts +4 -4
- package/src/modules/p2p.test.ts +4 -8
- package/src/modules/p2p.ts +0 -3
- package/src/node/credential-store.ts +311 -0
- package/src/oauth.ts +2 -2
- package/src/services.generated.ts +2 -2
- package/src/services.test.ts +29 -29
- package/src/services.ts +19 -19
- package/src/widgets.test.ts +14 -14
- package/src/widgets.ts +8 -8
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ export {
|
|
|
10
10
|
} from "./client.js";
|
|
11
11
|
export type { TopoloCredential, AgentIdentity } from "./auth.js";
|
|
12
12
|
export {
|
|
13
|
-
|
|
13
|
+
DEFAULT_APP_URLS,
|
|
14
14
|
APPLICATIONS,
|
|
15
15
|
APPLICATION_REQUIREMENTS,
|
|
16
16
|
APPLICATION_REQUIREMENTS_VERSION,
|
|
@@ -32,7 +32,7 @@ export {
|
|
|
32
32
|
type ApplicationRequirementStatus,
|
|
33
33
|
type ApplicationRequirementsAuditReport,
|
|
34
34
|
type ServiceCatalogUrlEntry,
|
|
35
|
-
type
|
|
35
|
+
type AppApiId,
|
|
36
36
|
} from "./services.js";
|
|
37
37
|
export {
|
|
38
38
|
TopoloSdkError,
|
package/src/modules/crm.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
|
9
9
|
const impl = vi.fn(async (url: string, init: RequestInit) => {
|
|
10
10
|
calls.push({ url, init });
|
|
11
11
|
if (String(url).includes('/api/services/by-slug/')) {
|
|
12
|
-
return new Response(JSON.stringify({ success: true, data: {
|
|
12
|
+
return new Response(JSON.stringify({ success: true, data: { app_id: 'service_crm' } }), {
|
|
13
13
|
status: 200,
|
|
14
14
|
headers: { 'Content-Type': 'application/json' },
|
|
15
15
|
});
|
|
@@ -20,14 +20,14 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
|
20
20
|
data: {
|
|
21
21
|
services: [
|
|
22
22
|
{
|
|
23
|
-
|
|
23
|
+
app_id: 'app_crm',
|
|
24
24
|
app_slug: 'topolo-crm',
|
|
25
25
|
name: 'Topolo CRM',
|
|
26
26
|
api_base_url: 'https://crm.topolo.app',
|
|
27
27
|
launch_url: 'https://crm.topolo.app',
|
|
28
28
|
surface_kind: 'api',
|
|
29
29
|
status: 'active',
|
|
30
|
-
granted_permissions: ['
|
|
30
|
+
granted_permissions: ['app_crm.contacts:read'],
|
|
31
31
|
},
|
|
32
32
|
],
|
|
33
33
|
},
|
|
@@ -61,7 +61,7 @@ function clientWith(impl: typeof fetch) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
beforeEach(() => {
|
|
64
|
-
TopoloClient.
|
|
64
|
+
TopoloClient.clearAppIdCache();
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
describe('CrmModule.listContacts', () => {
|
|
@@ -9,7 +9,7 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
|
9
9
|
const impl = vi.fn(async (url: string, init: RequestInit) => {
|
|
10
10
|
calls.push({ url, init });
|
|
11
11
|
if (String(url).includes('/api/services/by-slug/')) {
|
|
12
|
-
return new Response(JSON.stringify({ success: true, data: {
|
|
12
|
+
return new Response(JSON.stringify({ success: true, data: { app_id: 'service_auth' } }), {
|
|
13
13
|
status: 200,
|
|
14
14
|
headers: { 'Content-Type': 'application/json' },
|
|
15
15
|
});
|
|
@@ -48,12 +48,12 @@ function clientWith(
|
|
|
48
48
|
credential,
|
|
49
49
|
agent: AGENT,
|
|
50
50
|
fetch: impl,
|
|
51
|
-
|
|
51
|
+
appIds: { auth: 'service_auth' },
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
beforeEach(() => {
|
|
56
|
-
TopoloClient.
|
|
56
|
+
TopoloClient.clearAppIdCache();
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
describe('IdentityModule.whoami', () => {
|
|
@@ -93,7 +93,7 @@ describe('IdentityModule.whoami', () => {
|
|
|
93
93
|
{
|
|
94
94
|
body: {
|
|
95
95
|
credentialType: 'api_key',
|
|
96
|
-
user: { id: '
|
|
96
|
+
user: { id: 'usr_1', email: 'user@example.com' },
|
|
97
97
|
organization: { id: 'org_2', slug: 'beta' },
|
|
98
98
|
},
|
|
99
99
|
},
|
package/src/modules/p2p.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
|
9
9
|
const impl = vi.fn(async (url: string, init: RequestInit) => {
|
|
10
10
|
calls.push({ url, init });
|
|
11
11
|
if (String(url).includes('/api/services/by-slug/')) {
|
|
12
|
-
return new Response(JSON.stringify({ success: true, data: {
|
|
12
|
+
return new Response(JSON.stringify({ success: true, data: { app_id: 'service_p2p' } }), {
|
|
13
13
|
status: 200,
|
|
14
14
|
headers: { 'Content-Type': 'application/json' },
|
|
15
15
|
});
|
|
@@ -19,14 +19,14 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
|
19
19
|
data: {
|
|
20
20
|
services: [
|
|
21
21
|
{
|
|
22
|
-
|
|
22
|
+
app_id: 'app_p2p',
|
|
23
23
|
app_slug: 'topolo-p2p',
|
|
24
24
|
name: 'Topolo P2P',
|
|
25
25
|
api_base_url: 'https://p2p.topolo.app',
|
|
26
26
|
launch_url: 'https://p2p.topolo.app',
|
|
27
27
|
surface_kind: 'api',
|
|
28
28
|
status: 'active',
|
|
29
|
-
granted_permissions: ['
|
|
29
|
+
granted_permissions: ['app_p2p.directory:read'],
|
|
30
30
|
},
|
|
31
31
|
],
|
|
32
32
|
},
|
|
@@ -61,7 +61,7 @@ function clientWith(impl: typeof fetch) {
|
|
|
61
61
|
|
|
62
62
|
describe('P2PModule.listDirectory', () => {
|
|
63
63
|
beforeEach(() => {
|
|
64
|
-
TopoloClient.
|
|
64
|
+
TopoloClient.clearAppIdCache();
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
it('GETs /api/directory/capabilities and unwraps the envelope', async () => {
|
|
@@ -75,7 +75,6 @@ describe('P2PModule.listDirectory', () => {
|
|
|
75
75
|
capability_id: 'cap_1',
|
|
76
76
|
publisher_org_id: 'org_a',
|
|
77
77
|
app_id: 'app_x',
|
|
78
|
-
service_id: 'service_x',
|
|
79
78
|
name: 'Demo',
|
|
80
79
|
description: 'A capability',
|
|
81
80
|
category: 'other',
|
|
@@ -117,7 +116,6 @@ describe('P2PModule.publishCapability', () => {
|
|
|
117
116
|
capability_id: 'cap_z',
|
|
118
117
|
publisher_org_id: 'org_p',
|
|
119
118
|
app_id: 'app_x',
|
|
120
|
-
service_id: 'service_x',
|
|
121
119
|
name: 'Demo',
|
|
122
120
|
description: 'A capability',
|
|
123
121
|
category: 'other',
|
|
@@ -140,7 +138,6 @@ describe('P2PModule.publishCapability', () => {
|
|
|
140
138
|
capability_id: 'cap_z',
|
|
141
139
|
publisher_org_id: 'org_p',
|
|
142
140
|
app_id: 'app_x',
|
|
143
|
-
service_id: 'service_x',
|
|
144
141
|
name: 'Demo',
|
|
145
142
|
description: 'A capability',
|
|
146
143
|
category: 'other',
|
|
@@ -174,7 +171,6 @@ describe('P2PModule.publishCapability', () => {
|
|
|
174
171
|
capability_id: 'cap_other',
|
|
175
172
|
publisher_org_id: 'org_target',
|
|
176
173
|
app_id: 'app_x',
|
|
177
|
-
service_id: 'service_x',
|
|
178
174
|
name: 'Demo',
|
|
179
175
|
description: '...',
|
|
180
176
|
category: 'other',
|
package/src/modules/p2p.ts
CHANGED
|
@@ -6,7 +6,6 @@ export interface P2PCapability {
|
|
|
6
6
|
capability_id: string;
|
|
7
7
|
publisher_org_id: string;
|
|
8
8
|
app_id: string;
|
|
9
|
-
service_id: string;
|
|
10
9
|
name: string;
|
|
11
10
|
description: string;
|
|
12
11
|
category: string;
|
|
@@ -26,7 +25,6 @@ export interface PublishCapabilityInput {
|
|
|
26
25
|
capability_id: string;
|
|
27
26
|
publisher_org_id: string;
|
|
28
27
|
app_id: string;
|
|
29
|
-
service_id: string;
|
|
30
28
|
name: string;
|
|
31
29
|
description: string;
|
|
32
30
|
category: string;
|
|
@@ -231,7 +229,6 @@ export class P2PModule {
|
|
|
231
229
|
capability_id: input.capability_id,
|
|
232
230
|
publisher_org_id: input.publisher_org_id,
|
|
233
231
|
app_id: input.app_id,
|
|
234
|
-
service_id: input.service_id,
|
|
235
232
|
name: input.name,
|
|
236
233
|
description: input.description,
|
|
237
234
|
category: input.category,
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { TopoloOAuth } from '../oauth.js';
|
|
6
|
+
import type { TopoloCredential } from '../auth.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Node-only credential store shared by the `topolo` CLI and `@topolo/mcp`.
|
|
10
|
+
*
|
|
11
|
+
* Both need to read (and refresh) the credential that `topolo auth login`
|
|
12
|
+
* writes to `$XDG_CONFIG_HOME/topolo/config.json`. This lives in a Node-only
|
|
13
|
+
* subpath (`@topolo/sdk/node`) so the isomorphic SDK core never imports
|
|
14
|
+
* `node:fs` — importing this module in a browser/worker bundle is a mistake.
|
|
15
|
+
*
|
|
16
|
+
* The CLI re-exports every symbol here from `packages/topolo-cli/src/config.ts`
|
|
17
|
+
* so existing call sites are unchanged; the MCP imports it directly to fall
|
|
18
|
+
* back to the on-disk credential when no `TOPOLO_*` env credential is set.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const TOPOLO_ENVIRONMENTS = ['production', 'staging', 'development', 'local'] as const;
|
|
22
|
+
export type TopoloEnvironment = (typeof TOPOLO_ENVIRONMENTS)[number];
|
|
23
|
+
|
|
24
|
+
export interface TopoloConfig {
|
|
25
|
+
credential?: TopoloCredential;
|
|
26
|
+
/** OAuth client_id used for the most recent login — needed to refresh the access token. */
|
|
27
|
+
oauthClientId?: string;
|
|
28
|
+
/** Per-service URL overrides — written when user runs `topolo config set-url`. */
|
|
29
|
+
serviceUrls?: Record<string, string>;
|
|
30
|
+
/** Optional agent label propagated as `X-Topolo-Agent`. */
|
|
31
|
+
agentName?: string;
|
|
32
|
+
/** Environment-scoped profiles. Legacy top-level fields are treated as production. */
|
|
33
|
+
profiles?: Partial<Record<TopoloEnvironment, TopoloEnvironmentProfile>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TopoloEnvironmentProfile {
|
|
37
|
+
credential?: TopoloCredential;
|
|
38
|
+
oauthClientId?: string;
|
|
39
|
+
serviceUrls?: Record<string, string>;
|
|
40
|
+
agentName?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Default service URLs per environment. `local` has no baked defaults — a
|
|
45
|
+
* local dev loop points services at ad-hoc ports, so it is driven entirely by
|
|
46
|
+
* `TOPOLO_LOCAL_SERVICE_URL_*` / `TOPOLO_SERVICE_URL_*` overrides.
|
|
47
|
+
*/
|
|
48
|
+
export const ENVIRONMENT_DEFAULT_SERVICE_URLS: Record<
|
|
49
|
+
TopoloEnvironment,
|
|
50
|
+
Record<string, string>
|
|
51
|
+
> = {
|
|
52
|
+
production: {
|
|
53
|
+
developers: 'https://developers.topolo.app',
|
|
54
|
+
},
|
|
55
|
+
staging: {
|
|
56
|
+
auth: 'https://auth.stg.topolo.us',
|
|
57
|
+
developers: 'https://developers.stg.topolo.us',
|
|
58
|
+
seed: 'https://seed.stg.topolo.us',
|
|
59
|
+
},
|
|
60
|
+
development: {
|
|
61
|
+
auth: 'https://auth.topolo.dev',
|
|
62
|
+
developers: 'https://developers.topolo.dev',
|
|
63
|
+
},
|
|
64
|
+
local: {},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function configPath(): string {
|
|
68
|
+
const override = process.env.TOPOLO_CONFIG_PATH;
|
|
69
|
+
if (override) return override;
|
|
70
|
+
const base = process.env.XDG_CONFIG_HOME
|
|
71
|
+
|| (platform() === 'win32'
|
|
72
|
+
? join(homedir(), 'AppData', 'Roaming')
|
|
73
|
+
: join(homedir(), '.config'));
|
|
74
|
+
return join(base, 'topolo', 'config.json');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function readConfig(): Promise<TopoloConfig> {
|
|
78
|
+
const path = configPath();
|
|
79
|
+
if (!existsSync(path)) return {};
|
|
80
|
+
try {
|
|
81
|
+
const raw = await readFile(path, 'utf8');
|
|
82
|
+
return JSON.parse(raw) as TopoloConfig;
|
|
83
|
+
} catch {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function writeConfig(config: TopoloConfig): Promise<void> {
|
|
89
|
+
const path = configPath();
|
|
90
|
+
await mkdir(dirname(path), { recursive: true });
|
|
91
|
+
await writeFile(path, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
92
|
+
if (platform() !== 'win32') {
|
|
93
|
+
try {
|
|
94
|
+
await chmod(path, 0o600);
|
|
95
|
+
} catch {
|
|
96
|
+
/* non-fatal */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function normalizeEnvironment(value: string | undefined): TopoloEnvironment {
|
|
102
|
+
const normalized = (value ?? 'production').trim().toLowerCase();
|
|
103
|
+
if (normalized === '' || normalized === 'production' || normalized === 'prod') {
|
|
104
|
+
return 'production';
|
|
105
|
+
}
|
|
106
|
+
if (normalized === 'staging' || normalized === 'stage' || normalized === 'stg') {
|
|
107
|
+
return 'staging';
|
|
108
|
+
}
|
|
109
|
+
if (normalized === 'development' || normalized === 'dev') {
|
|
110
|
+
return 'development';
|
|
111
|
+
}
|
|
112
|
+
if (normalized === 'local' || normalized === 'localhost') {
|
|
113
|
+
return 'local';
|
|
114
|
+
}
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Unknown Topolo environment "${value}". Expected production, staging, development, or local.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function selectedEnvironment(): TopoloEnvironment {
|
|
121
|
+
return normalizeEnvironment(process.env.TOPOLO_ENV);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function envVarPrefix(environment: TopoloEnvironment): string {
|
|
125
|
+
if (environment === 'production') return 'TOPOLO_PRODUCTION';
|
|
126
|
+
if (environment === 'staging') return 'TOPOLO_STAGING';
|
|
127
|
+
if (environment === 'development') return 'TOPOLO_DEVELOPMENT';
|
|
128
|
+
return 'TOPOLO_LOCAL';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function profileForEnvironment(
|
|
132
|
+
config: TopoloConfig,
|
|
133
|
+
environment: TopoloEnvironment = selectedEnvironment(),
|
|
134
|
+
): TopoloEnvironmentProfile {
|
|
135
|
+
const profile = config.profiles?.[environment] ?? {};
|
|
136
|
+
if (environment === 'production') {
|
|
137
|
+
return {
|
|
138
|
+
credential: config.credential,
|
|
139
|
+
oauthClientId: config.oauthClientId,
|
|
140
|
+
...profile,
|
|
141
|
+
serviceUrls: {
|
|
142
|
+
...(config.serviceUrls ?? {}),
|
|
143
|
+
...(profile.serviceUrls ?? {}),
|
|
144
|
+
},
|
|
145
|
+
agentName: profile.agentName ?? config.agentName,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return profile;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function writeProfileConfig(
|
|
152
|
+
config: TopoloConfig,
|
|
153
|
+
environment: TopoloEnvironment,
|
|
154
|
+
profile: TopoloEnvironmentProfile,
|
|
155
|
+
): TopoloConfig {
|
|
156
|
+
const profiles = { ...(config.profiles ?? {}) };
|
|
157
|
+
profiles[environment] = {
|
|
158
|
+
...(profiles[environment] ?? {}),
|
|
159
|
+
...profile,
|
|
160
|
+
};
|
|
161
|
+
return { ...config, profiles };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function clearProfileCredential(
|
|
165
|
+
config: TopoloConfig,
|
|
166
|
+
environment: TopoloEnvironment,
|
|
167
|
+
): TopoloConfig {
|
|
168
|
+
const next = { ...config };
|
|
169
|
+
if (environment === 'production') {
|
|
170
|
+
delete next.credential;
|
|
171
|
+
delete next.oauthClientId;
|
|
172
|
+
}
|
|
173
|
+
const profiles = { ...(config.profiles ?? {}) };
|
|
174
|
+
const profile = { ...(profiles[environment] ?? {}) };
|
|
175
|
+
delete profile.credential;
|
|
176
|
+
delete profile.oauthClientId;
|
|
177
|
+
profiles[environment] = profile;
|
|
178
|
+
return { ...next, profiles };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function credentialSourceFromConfig(
|
|
182
|
+
config: TopoloConfig,
|
|
183
|
+
environment: TopoloEnvironment = selectedEnvironment(),
|
|
184
|
+
): string {
|
|
185
|
+
const prefix = envVarPrefix(environment);
|
|
186
|
+
if (process.env[`${prefix}_API_KEY`]) return `env:${prefix}_API_KEY`;
|
|
187
|
+
if (process.env[`${prefix}_ACCESS_TOKEN`]) return `env:${prefix}_ACCESS_TOKEN`;
|
|
188
|
+
if (process.env.TOPOLO_API_KEY) return 'env:TOPOLO_API_KEY';
|
|
189
|
+
if (process.env.TOPOLO_ACCESS_TOKEN) return 'env:TOPOLO_ACCESS_TOKEN';
|
|
190
|
+
return profileForEnvironment(config, environment).credential ? 'config-file' : 'none';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resolve credentials with env-var precedence over on-disk config.
|
|
195
|
+
* Env vars are what agents set in their environment; config is what
|
|
196
|
+
* interactive users get after running `topolo auth login`.
|
|
197
|
+
*/
|
|
198
|
+
export async function resolveCredential(
|
|
199
|
+
environment: TopoloEnvironment = selectedEnvironment(),
|
|
200
|
+
): Promise<TopoloCredential | null> {
|
|
201
|
+
const prefix = envVarPrefix(environment);
|
|
202
|
+
const apiKey = process.env[`${prefix}_API_KEY`] ?? process.env.TOPOLO_API_KEY;
|
|
203
|
+
if (apiKey) return { kind: 'api_key', apiKey };
|
|
204
|
+
|
|
205
|
+
const accessToken = process.env[`${prefix}_ACCESS_TOKEN`] ?? process.env.TOPOLO_ACCESS_TOKEN;
|
|
206
|
+
if (accessToken) return { kind: 'access_token', accessToken };
|
|
207
|
+
|
|
208
|
+
const config = await readConfig();
|
|
209
|
+
return profileForEnvironment(config, environment).credential ?? null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* If the stored access_token is expired (or within the skew window) and a
|
|
214
|
+
* refresh_token + oauthClientId are present, exchange the refresh_token for a
|
|
215
|
+
* new pair and persist. On any failure returns the existing (stale) credential
|
|
216
|
+
* so the next API call fails naturally with auth_error — we don't nuke the
|
|
217
|
+
* user's config on a transient network blip.
|
|
218
|
+
*/
|
|
219
|
+
const REFRESH_SKEW_SECONDS = 60;
|
|
220
|
+
|
|
221
|
+
export async function refreshCredentialIfNeeded(
|
|
222
|
+
environment: TopoloEnvironment = selectedEnvironment(),
|
|
223
|
+
): Promise<TopoloCredential | null> {
|
|
224
|
+
const prefix = envVarPrefix(environment);
|
|
225
|
+
if (
|
|
226
|
+
process.env[`${prefix}_API_KEY`]
|
|
227
|
+
|| process.env[`${prefix}_ACCESS_TOKEN`]
|
|
228
|
+
|| process.env.TOPOLO_API_KEY
|
|
229
|
+
|| process.env.TOPOLO_ACCESS_TOKEN
|
|
230
|
+
) {
|
|
231
|
+
return resolveCredential(environment);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const config = await readConfig();
|
|
235
|
+
const profile = profileForEnvironment(config, environment);
|
|
236
|
+
const cred = profile.credential;
|
|
237
|
+
if (!cred || cred.kind !== 'access_token') return cred ?? null;
|
|
238
|
+
if (!cred.refreshToken || !cred.expiresAt || !profile.oauthClientId) return cred;
|
|
239
|
+
|
|
240
|
+
const now = Math.floor(Date.now() / 1000);
|
|
241
|
+
if (cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const oauth = new TopoloOAuth({
|
|
245
|
+
serviceUrls: resolveServiceUrls(profile.serviceUrls, environment) as Partial<
|
|
246
|
+
Record<'auth' | 'crm', string>
|
|
247
|
+
>,
|
|
248
|
+
});
|
|
249
|
+
const tokens = await oauth.refreshToken({
|
|
250
|
+
clientId: profile.oauthClientId,
|
|
251
|
+
refreshToken: cred.refreshToken,
|
|
252
|
+
});
|
|
253
|
+
const refreshed: TopoloCredential = {
|
|
254
|
+
kind: 'access_token',
|
|
255
|
+
accessToken: tokens.access_token,
|
|
256
|
+
...(tokens.refresh_token
|
|
257
|
+
? { refreshToken: tokens.refresh_token }
|
|
258
|
+
: { refreshToken: cred.refreshToken }),
|
|
259
|
+
expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in,
|
|
260
|
+
};
|
|
261
|
+
await writeConfig(
|
|
262
|
+
environment === 'production' && !config.profiles?.production
|
|
263
|
+
? { ...config, credential: refreshed }
|
|
264
|
+
: writeProfileConfig(config, environment, { credential: refreshed }),
|
|
265
|
+
);
|
|
266
|
+
return refreshed;
|
|
267
|
+
} catch {
|
|
268
|
+
return cred;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function resolveAgentName(configAgentName?: string): string | undefined {
|
|
273
|
+
return process.env.TOPOLO_AGENT_NAME ?? configAgentName;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function resolveServiceUrls(
|
|
277
|
+
configUrls?: Record<string, string>,
|
|
278
|
+
environment: TopoloEnvironment = selectedEnvironment(),
|
|
279
|
+
): Record<string, string> | undefined {
|
|
280
|
+
const merged: Record<string, string> = {
|
|
281
|
+
...ENVIRONMENT_DEFAULT_SERVICE_URLS[environment],
|
|
282
|
+
...(configUrls ?? {}),
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// Env wins. Pull env-specific and global service URL vars that might be set.
|
|
286
|
+
const prefix = `${envVarPrefix(environment)}_SERVICE_URL_`;
|
|
287
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
288
|
+
if (k.startsWith(prefix) && v) {
|
|
289
|
+
merged[k.slice(prefix.length).toLowerCase()] = v;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
293
|
+
if (k.startsWith('TOPOLO_SERVICE_URL_') && v) {
|
|
294
|
+
merged[k.slice('TOPOLO_SERVICE_URL_'.length).toLowerCase()] = v;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function resolvedProfileConfig(
|
|
301
|
+
config: TopoloConfig,
|
|
302
|
+
environment: TopoloEnvironment = selectedEnvironment(),
|
|
303
|
+
): TopoloEnvironmentProfile {
|
|
304
|
+
const profile = profileForEnvironment(config, environment);
|
|
305
|
+
return {
|
|
306
|
+
credential: profile.credential,
|
|
307
|
+
oauthClientId: profile.oauthClientId,
|
|
308
|
+
agentName: profile.agentName,
|
|
309
|
+
serviceUrls: resolveServiceUrls(profile.serviceUrls, environment),
|
|
310
|
+
};
|
|
311
|
+
}
|
package/src/oauth.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { TopoloAuthError, TopoloHttpError } from './errors.js';
|
|
2
|
-
import { resolveServiceUrl, type
|
|
2
|
+
import { resolveServiceUrl, type AppApiId } from './services.js';
|
|
3
3
|
|
|
4
4
|
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
5
5
|
|
|
@@ -22,7 +22,7 @@ export interface TokenResponse {
|
|
|
22
22
|
|
|
23
23
|
export interface OAuthHelperOptions {
|
|
24
24
|
/** Overrides for TopoloAuth URL. Staging/dev should pass a mapping. */
|
|
25
|
-
serviceUrls?: Partial<Record<
|
|
25
|
+
serviceUrls?: Partial<Record<AppApiId, string>>;
|
|
26
26
|
/** Injected fetch. Defaults to global fetch. */
|
|
27
27
|
fetch?: typeof fetch;
|
|
28
28
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Source of truth: topolo.cloudcontrol.json in every app repo.
|
|
3
3
|
// Regenerate: npm run generate:services
|
|
4
4
|
|
|
5
|
-
export const
|
|
5
|
+
export const APP_API_URLS = {
|
|
6
6
|
agent: "https://agent.topolo.app",
|
|
7
7
|
auth: "https://auth.topolo.app",
|
|
8
8
|
backup: "https://backup.topolo.app",
|
|
@@ -52,4 +52,4 @@ export const SERVICES = {
|
|
|
52
52
|
web: "https://web.topolo.app",
|
|
53
53
|
} as const;
|
|
54
54
|
|
|
55
|
-
export type
|
|
55
|
+
export type AppApiId = keyof typeof APP_API_URLS;
|
package/src/services.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
APPLICATION_REQUIREMENTS,
|
|
4
4
|
APPLICATION_REQUIREMENTS_VERSION,
|
|
5
5
|
APPLICATIONS,
|
|
6
|
-
|
|
6
|
+
DEFAULT_APP_URLS,
|
|
7
7
|
auditAllApplicationRequirements,
|
|
8
8
|
auditApplicationRequirements,
|
|
9
9
|
applicationRequirementScopes,
|
|
@@ -62,9 +62,9 @@ const EXPECTED_APPLICATION_IDS = [
|
|
|
62
62
|
'web',
|
|
63
63
|
];
|
|
64
64
|
|
|
65
|
-
describe('
|
|
65
|
+
describe('app API registry', () => {
|
|
66
66
|
it('includes every production worker API that belongs to a platform application', () => {
|
|
67
|
-
expect(
|
|
67
|
+
expect(DEFAULT_APP_URLS).toMatchObject({
|
|
68
68
|
calendar: 'https://calendar.topolo.app',
|
|
69
69
|
books: 'https://books.topolo.app',
|
|
70
70
|
consent: 'https://consent.topolo.app',
|
|
@@ -73,34 +73,34 @@ describe('service registry', () => {
|
|
|
73
73
|
one: 'https://www.topolo.app',
|
|
74
74
|
web: 'https://web.topolo.app',
|
|
75
75
|
});
|
|
76
|
-
expect(
|
|
76
|
+
expect(DEFAULT_APP_URLS).toMatchObject({
|
|
77
77
|
success: 'https://success.topolo.app',
|
|
78
78
|
forms: 'https://forms.topolo.app',
|
|
79
79
|
survey: 'https://survey.topolo.app',
|
|
80
80
|
});
|
|
81
|
-
expect(
|
|
82
|
-
expect(
|
|
81
|
+
expect(DEFAULT_APP_URLS).not.toHaveProperty('web.api');
|
|
82
|
+
expect(DEFAULT_APP_URLS).not.toHaveProperty('web.runtime');
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
it('does not bake opaque Auth
|
|
85
|
+
it('does not bake opaque Auth app ids into the generated registry', async () => {
|
|
86
86
|
const services = await import('./services.js');
|
|
87
87
|
|
|
88
|
-
expect(services).not.toHaveProperty('
|
|
88
|
+
expect(services).not.toHaveProperty('PLATFORM_APP_IDS');
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
it('maps every callable
|
|
91
|
+
it('maps every callable app API back to an application catalog entry', () => {
|
|
92
92
|
const applicationServices = new Set<string>(
|
|
93
93
|
Object.values(APPLICATIONS).flatMap((application) => [...application.services]),
|
|
94
94
|
);
|
|
95
95
|
|
|
96
|
-
for (const
|
|
97
|
-
expect(applicationServices.has(
|
|
96
|
+
for (const appId of Object.keys(DEFAULT_APP_URLS)) {
|
|
97
|
+
expect(applicationServices.has(appId)).toBe(true);
|
|
98
98
|
}
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
it('resolves services from explicit catalog API URLs', () => {
|
|
102
102
|
expect(resolveCatalogServiceUrl({
|
|
103
|
-
|
|
103
|
+
appId: 'app_crm',
|
|
104
104
|
slug: 'topolo-crm',
|
|
105
105
|
name: 'Topolo CRM',
|
|
106
106
|
launchUrl: 'https://crm.topolo.app',
|
|
@@ -110,7 +110,7 @@ describe('service registry', () => {
|
|
|
110
110
|
|
|
111
111
|
it('does not infer callable URLs from launcher URLs', () => {
|
|
112
112
|
expect(resolveCatalogServiceUrl({
|
|
113
|
-
|
|
113
|
+
appId: 'app_crm',
|
|
114
114
|
slug: 'topolo-crm',
|
|
115
115
|
name: 'Topolo CRM',
|
|
116
116
|
launchUrl: 'https://crm.stg.topolo.us',
|
|
@@ -119,7 +119,7 @@ describe('service registry', () => {
|
|
|
119
119
|
|
|
120
120
|
it('prefers explicit catalog API URLs over launch URLs', () => {
|
|
121
121
|
expect(resolveCatalogServiceUrl({
|
|
122
|
-
|
|
122
|
+
appId: 'app_future',
|
|
123
123
|
slug: 'topolo-future',
|
|
124
124
|
name: 'Future',
|
|
125
125
|
launchUrl: 'https://future.topolo.app',
|
|
@@ -133,15 +133,15 @@ describe('application catalog', () => {
|
|
|
133
133
|
expect(Object.keys(APPLICATIONS).sort()).toEqual(EXPECTED_APPLICATION_IDS);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
it('only references registered
|
|
137
|
-
const registeredServices = new Set<string>(Object.keys(
|
|
136
|
+
it('only references registered app API ids from application metadata', () => {
|
|
137
|
+
const registeredServices = new Set<string>(Object.keys(DEFAULT_APP_URLS));
|
|
138
138
|
|
|
139
139
|
for (const application of Object.values(APPLICATIONS)) {
|
|
140
|
-
for (const
|
|
141
|
-
expect(registeredServices.has(
|
|
140
|
+
for (const appId of application.services) {
|
|
141
|
+
expect(registeredServices.has(appId)).toBe(true);
|
|
142
142
|
}
|
|
143
143
|
for (const target of application.deployTargets) {
|
|
144
|
-
if (target.
|
|
144
|
+
if (target.appId) expect(registeredServices.has(target.appId)).toBe(true);
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
});
|
|
@@ -152,19 +152,19 @@ describe('application catalog', () => {
|
|
|
152
152
|
// studio web worker (web.topolo.app/api); the API worker is de-routed and
|
|
153
153
|
// no longer carries a public service registration.
|
|
154
154
|
expect(
|
|
155
|
-
APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-studio')?.
|
|
155
|
+
APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-studio')?.appId,
|
|
156
156
|
).toBe('web');
|
|
157
157
|
expect(
|
|
158
|
-
APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-api')?.
|
|
158
|
+
APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-api')?.appId,
|
|
159
159
|
).toBeNull();
|
|
160
160
|
// topolo-web-runtime (Astro, sites.topolo.app) deploys from its own wrangler
|
|
161
161
|
// config and is intentionally listed as non-callable, so it can never be an
|
|
162
|
-
// agent-callable
|
|
162
|
+
// agent-callable app API.
|
|
163
163
|
expect(
|
|
164
|
-
APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-runtime')?.
|
|
164
|
+
APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-runtime')?.appId,
|
|
165
165
|
).toBeNull();
|
|
166
166
|
const callableTargetNames = APPLICATIONS.web.deployTargets
|
|
167
|
-
.filter((target) => target.
|
|
167
|
+
.filter((target) => target.appId)
|
|
168
168
|
.map((target) => target.name);
|
|
169
169
|
expect(callableTargetNames).not.toContain('topolo-web-runtime');
|
|
170
170
|
});
|
|
@@ -174,14 +174,14 @@ describe('application catalog', () => {
|
|
|
174
174
|
// Front-door standardization: the callable `feed` service is served by the
|
|
175
175
|
// feed web worker (feed.topolo.app/api); the API worker is de-routed.
|
|
176
176
|
expect(
|
|
177
|
-
APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-web')?.
|
|
177
|
+
APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-web')?.appId,
|
|
178
178
|
).toBe('feed');
|
|
179
179
|
expect(
|
|
180
|
-
APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-api')?.
|
|
180
|
+
APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-api')?.appId,
|
|
181
181
|
).toBeNull();
|
|
182
182
|
// The analytics ingestion worker keeps its own host and is non-callable.
|
|
183
183
|
expect(
|
|
184
|
-
APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-analytics-api')?.
|
|
184
|
+
APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-analytics-api')?.appId,
|
|
185
185
|
).toBeNull();
|
|
186
186
|
});
|
|
187
187
|
});
|
|
@@ -214,7 +214,7 @@ describe('application requirements', () => {
|
|
|
214
214
|
expect(adminRequirementIds).toContain('shared-shell-and-launcher');
|
|
215
215
|
expect(adminRequirementIds).not.toContain('organization-scoped-data');
|
|
216
216
|
expect(mailRequirementIds).toContain('organization-scoped-data');
|
|
217
|
-
expect(mailRequirementIds).toContain('
|
|
217
|
+
expect(mailRequirementIds).toContain('app-registration-and-scopes');
|
|
218
218
|
expect(mailRequirementIds).toContain('native-dashboard-widget');
|
|
219
219
|
});
|
|
220
220
|
|
|
@@ -226,7 +226,7 @@ describe('application requirements', () => {
|
|
|
226
226
|
expect(audit.application.id).toBe('mail');
|
|
227
227
|
expect(audit.score.total).toBe(audit.findings.length);
|
|
228
228
|
expect(byRequirement.get('platform-metadata')?.status).toBe('met');
|
|
229
|
-
expect(byRequirement.get('
|
|
229
|
+
expect(byRequirement.get('app-registration-and-scopes')?.status).toBe('partial');
|
|
230
230
|
expect(byRequirement.get('canonical-docs')?.status).toBe('needs_review');
|
|
231
231
|
});
|
|
232
232
|
|