@topolo/sdk 0.5.0 → 0.7.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 +3 -3
- package/dist/index.cjs +27 -24
- package/dist/index.d.cts +20 -20
- package/dist/index.d.ts +20 -20
- package/dist/index.js +26 -23
- package/dist/node/credential-store.cjs +471 -0
- package/dist/node/credential-store.d.cts +108 -0
- package/dist/node/credential-store.d.ts +108 -0
- package/dist/node/credential-store.js +427 -0
- package/package.json +7 -2
- package/src/application-requirements.ts +13 -13
- package/src/client.test.ts +7 -7
- package/src/client.ts +10 -10
- package/src/index.ts +2 -2
- package/src/node/credential-store.ts +378 -0
- package/src/oauth.ts +2 -2
- package/src/services.generated.ts +2 -2
- package/src/services.test.ts +15 -15
- package/src/services.ts +17 -17
|
@@ -0,0 +1,378 @@
|
|
|
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
|
+
* The context (`personal` or `org:<orgId>`) whose credential is currently
|
|
43
|
+
* active. `credential` above always mirrors `contexts[activeContext]`, so
|
|
44
|
+
* credential resolution + the MCP keep working unchanged while `contexts`
|
|
45
|
+
* lets the CLI hold and switch between multiple org/personal contexts.
|
|
46
|
+
*/
|
|
47
|
+
activeContext?: string;
|
|
48
|
+
contexts?: Record<string, TopoloContextCredential>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A signed-in context — personal, or a specific organization. */
|
|
52
|
+
export interface TopoloContextCredential {
|
|
53
|
+
contextType: 'personal' | 'organization';
|
|
54
|
+
organizationId?: string;
|
|
55
|
+
organizationSlug?: string;
|
|
56
|
+
organizationName?: string;
|
|
57
|
+
credential?: TopoloCredential;
|
|
58
|
+
oauthClientId?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stable key for a context within a profile's `contexts` map. */
|
|
62
|
+
export function contextKey(
|
|
63
|
+
contextType: 'personal' | 'organization',
|
|
64
|
+
organizationId?: string | null,
|
|
65
|
+
): string {
|
|
66
|
+
return contextType === 'organization' && organizationId
|
|
67
|
+
? `org:${organizationId}`
|
|
68
|
+
: 'personal';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Default service URLs per environment. `local` has no baked defaults — a
|
|
73
|
+
* local dev loop points services at ad-hoc ports, so it is driven entirely by
|
|
74
|
+
* `TOPOLO_LOCAL_SERVICE_URL_*` / `TOPOLO_SERVICE_URL_*` overrides.
|
|
75
|
+
*/
|
|
76
|
+
export const ENVIRONMENT_DEFAULT_SERVICE_URLS: Record<
|
|
77
|
+
TopoloEnvironment,
|
|
78
|
+
Record<string, string>
|
|
79
|
+
> = {
|
|
80
|
+
production: {
|
|
81
|
+
developers: 'https://developers.topolo.app',
|
|
82
|
+
},
|
|
83
|
+
staging: {
|
|
84
|
+
auth: 'https://auth.stg.topolo.us',
|
|
85
|
+
developers: 'https://developers.stg.topolo.us',
|
|
86
|
+
seed: 'https://seed.stg.topolo.us',
|
|
87
|
+
},
|
|
88
|
+
development: {
|
|
89
|
+
auth: 'https://auth.topolo.dev',
|
|
90
|
+
developers: 'https://developers.topolo.dev',
|
|
91
|
+
},
|
|
92
|
+
local: {},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export function configPath(): string {
|
|
96
|
+
const override = process.env.TOPOLO_CONFIG_PATH;
|
|
97
|
+
if (override) return override;
|
|
98
|
+
const base = process.env.XDG_CONFIG_HOME
|
|
99
|
+
|| (platform() === 'win32'
|
|
100
|
+
? join(homedir(), 'AppData', 'Roaming')
|
|
101
|
+
: join(homedir(), '.config'));
|
|
102
|
+
return join(base, 'topolo', 'config.json');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function readConfig(): Promise<TopoloConfig> {
|
|
106
|
+
const path = configPath();
|
|
107
|
+
if (!existsSync(path)) return {};
|
|
108
|
+
try {
|
|
109
|
+
const raw = await readFile(path, 'utf8');
|
|
110
|
+
return JSON.parse(raw) as TopoloConfig;
|
|
111
|
+
} catch {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function writeConfig(config: TopoloConfig): Promise<void> {
|
|
117
|
+
const path = configPath();
|
|
118
|
+
await mkdir(dirname(path), { recursive: true });
|
|
119
|
+
await writeFile(path, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
120
|
+
if (platform() !== 'win32') {
|
|
121
|
+
try {
|
|
122
|
+
await chmod(path, 0o600);
|
|
123
|
+
} catch {
|
|
124
|
+
/* non-fatal */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function normalizeEnvironment(value: string | undefined): TopoloEnvironment {
|
|
130
|
+
const normalized = (value ?? 'production').trim().toLowerCase();
|
|
131
|
+
if (normalized === '' || normalized === 'production' || normalized === 'prod') {
|
|
132
|
+
return 'production';
|
|
133
|
+
}
|
|
134
|
+
if (normalized === 'staging' || normalized === 'stage' || normalized === 'stg') {
|
|
135
|
+
return 'staging';
|
|
136
|
+
}
|
|
137
|
+
if (normalized === 'development' || normalized === 'dev') {
|
|
138
|
+
return 'development';
|
|
139
|
+
}
|
|
140
|
+
if (normalized === 'local' || normalized === 'localhost') {
|
|
141
|
+
return 'local';
|
|
142
|
+
}
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Unknown Topolo environment "${value}". Expected production, staging, development, or local.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function selectedEnvironment(): TopoloEnvironment {
|
|
149
|
+
return normalizeEnvironment(process.env.TOPOLO_ENV);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function envVarPrefix(environment: TopoloEnvironment): string {
|
|
153
|
+
if (environment === 'production') return 'TOPOLO_PRODUCTION';
|
|
154
|
+
if (environment === 'staging') return 'TOPOLO_STAGING';
|
|
155
|
+
if (environment === 'development') return 'TOPOLO_DEVELOPMENT';
|
|
156
|
+
return 'TOPOLO_LOCAL';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function profileForEnvironment(
|
|
160
|
+
config: TopoloConfig,
|
|
161
|
+
environment: TopoloEnvironment = selectedEnvironment(),
|
|
162
|
+
): TopoloEnvironmentProfile {
|
|
163
|
+
const profile = config.profiles?.[environment] ?? {};
|
|
164
|
+
if (environment === 'production') {
|
|
165
|
+
return {
|
|
166
|
+
credential: config.credential,
|
|
167
|
+
oauthClientId: config.oauthClientId,
|
|
168
|
+
...profile,
|
|
169
|
+
serviceUrls: {
|
|
170
|
+
...(config.serviceUrls ?? {}),
|
|
171
|
+
...(profile.serviceUrls ?? {}),
|
|
172
|
+
},
|
|
173
|
+
agentName: profile.agentName ?? config.agentName,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return profile;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function writeProfileConfig(
|
|
180
|
+
config: TopoloConfig,
|
|
181
|
+
environment: TopoloEnvironment,
|
|
182
|
+
profile: TopoloEnvironmentProfile,
|
|
183
|
+
): TopoloConfig {
|
|
184
|
+
const profiles = { ...(config.profiles ?? {}) };
|
|
185
|
+
profiles[environment] = {
|
|
186
|
+
...(profiles[environment] ?? {}),
|
|
187
|
+
...profile,
|
|
188
|
+
};
|
|
189
|
+
return { ...config, profiles };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Store a context's credential in the profile's `contexts` map. When
|
|
194
|
+
* `makeActive` (default), also point the profile's active credential at it so
|
|
195
|
+
* credential resolution + the MCP use this context.
|
|
196
|
+
*/
|
|
197
|
+
export function writeContextCredential(
|
|
198
|
+
config: TopoloConfig,
|
|
199
|
+
environment: TopoloEnvironment,
|
|
200
|
+
ctx: TopoloContextCredential,
|
|
201
|
+
makeActive = true,
|
|
202
|
+
): TopoloConfig {
|
|
203
|
+
const key = contextKey(ctx.contextType, ctx.organizationId);
|
|
204
|
+
const profile = profileForEnvironment(config, environment);
|
|
205
|
+
const contexts = { ...(profile.contexts ?? {}), [key]: ctx };
|
|
206
|
+
const next: TopoloEnvironmentProfile = { contexts };
|
|
207
|
+
if (makeActive) {
|
|
208
|
+
next.activeContext = key;
|
|
209
|
+
next.credential = ctx.credential;
|
|
210
|
+
next.oauthClientId = ctx.oauthClientId ?? profile.oauthClientId;
|
|
211
|
+
}
|
|
212
|
+
return writeProfileConfig(config, environment, next);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Point the profile's active credential at an already-stored context. */
|
|
216
|
+
export function activateContext(
|
|
217
|
+
config: TopoloConfig,
|
|
218
|
+
environment: TopoloEnvironment,
|
|
219
|
+
key: string,
|
|
220
|
+
): TopoloConfig {
|
|
221
|
+
const profile = profileForEnvironment(config, environment);
|
|
222
|
+
const ctx = profile.contexts?.[key];
|
|
223
|
+
if (!ctx) return config;
|
|
224
|
+
return writeProfileConfig(config, environment, {
|
|
225
|
+
activeContext: key,
|
|
226
|
+
credential: ctx.credential,
|
|
227
|
+
oauthClientId: ctx.oauthClientId ?? profile.oauthClientId,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function clearProfileCredential(
|
|
232
|
+
config: TopoloConfig,
|
|
233
|
+
environment: TopoloEnvironment,
|
|
234
|
+
): TopoloConfig {
|
|
235
|
+
const next = { ...config };
|
|
236
|
+
if (environment === 'production') {
|
|
237
|
+
delete next.credential;
|
|
238
|
+
delete next.oauthClientId;
|
|
239
|
+
}
|
|
240
|
+
const profiles = { ...(config.profiles ?? {}) };
|
|
241
|
+
const profile = { ...(profiles[environment] ?? {}) };
|
|
242
|
+
delete profile.credential;
|
|
243
|
+
delete profile.oauthClientId;
|
|
244
|
+
profiles[environment] = profile;
|
|
245
|
+
return { ...next, profiles };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function credentialSourceFromConfig(
|
|
249
|
+
config: TopoloConfig,
|
|
250
|
+
environment: TopoloEnvironment = selectedEnvironment(),
|
|
251
|
+
): string {
|
|
252
|
+
const prefix = envVarPrefix(environment);
|
|
253
|
+
if (process.env[`${prefix}_API_KEY`]) return `env:${prefix}_API_KEY`;
|
|
254
|
+
if (process.env[`${prefix}_ACCESS_TOKEN`]) return `env:${prefix}_ACCESS_TOKEN`;
|
|
255
|
+
if (process.env.TOPOLO_API_KEY) return 'env:TOPOLO_API_KEY';
|
|
256
|
+
if (process.env.TOPOLO_ACCESS_TOKEN) return 'env:TOPOLO_ACCESS_TOKEN';
|
|
257
|
+
return profileForEnvironment(config, environment).credential ? 'config-file' : 'none';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Resolve credentials with env-var precedence over on-disk config.
|
|
262
|
+
* Env vars are what agents set in their environment; config is what
|
|
263
|
+
* interactive users get after running `topolo auth login`.
|
|
264
|
+
*/
|
|
265
|
+
export async function resolveCredential(
|
|
266
|
+
environment: TopoloEnvironment = selectedEnvironment(),
|
|
267
|
+
): Promise<TopoloCredential | null> {
|
|
268
|
+
const prefix = envVarPrefix(environment);
|
|
269
|
+
const apiKey = process.env[`${prefix}_API_KEY`] ?? process.env.TOPOLO_API_KEY;
|
|
270
|
+
if (apiKey) return { kind: 'api_key', apiKey };
|
|
271
|
+
|
|
272
|
+
const accessToken = process.env[`${prefix}_ACCESS_TOKEN`] ?? process.env.TOPOLO_ACCESS_TOKEN;
|
|
273
|
+
if (accessToken) return { kind: 'access_token', accessToken };
|
|
274
|
+
|
|
275
|
+
const config = await readConfig();
|
|
276
|
+
return profileForEnvironment(config, environment).credential ?? null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* If the stored access_token is expired (or within the skew window) and a
|
|
281
|
+
* refresh_token + oauthClientId are present, exchange the refresh_token for a
|
|
282
|
+
* new pair and persist. On any failure returns the existing (stale) credential
|
|
283
|
+
* so the next API call fails naturally with auth_error — we don't nuke the
|
|
284
|
+
* user's config on a transient network blip.
|
|
285
|
+
*/
|
|
286
|
+
const REFRESH_SKEW_SECONDS = 60;
|
|
287
|
+
|
|
288
|
+
export async function refreshCredentialIfNeeded(
|
|
289
|
+
environment: TopoloEnvironment = selectedEnvironment(),
|
|
290
|
+
): Promise<TopoloCredential | null> {
|
|
291
|
+
const prefix = envVarPrefix(environment);
|
|
292
|
+
if (
|
|
293
|
+
process.env[`${prefix}_API_KEY`]
|
|
294
|
+
|| process.env[`${prefix}_ACCESS_TOKEN`]
|
|
295
|
+
|| process.env.TOPOLO_API_KEY
|
|
296
|
+
|| process.env.TOPOLO_ACCESS_TOKEN
|
|
297
|
+
) {
|
|
298
|
+
return resolveCredential(environment);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const config = await readConfig();
|
|
302
|
+
const profile = profileForEnvironment(config, environment);
|
|
303
|
+
const cred = profile.credential;
|
|
304
|
+
if (!cred || cred.kind !== 'access_token') return cred ?? null;
|
|
305
|
+
if (!cred.refreshToken || !cred.expiresAt || !profile.oauthClientId) return cred;
|
|
306
|
+
|
|
307
|
+
const now = Math.floor(Date.now() / 1000);
|
|
308
|
+
if (cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const oauth = new TopoloOAuth({
|
|
312
|
+
serviceUrls: resolveServiceUrls(profile.serviceUrls, environment) as Partial<
|
|
313
|
+
Record<'auth' | 'crm', string>
|
|
314
|
+
>,
|
|
315
|
+
});
|
|
316
|
+
const tokens = await oauth.refreshToken({
|
|
317
|
+
clientId: profile.oauthClientId,
|
|
318
|
+
refreshToken: cred.refreshToken,
|
|
319
|
+
});
|
|
320
|
+
const refreshed: TopoloCredential = {
|
|
321
|
+
kind: 'access_token',
|
|
322
|
+
accessToken: tokens.access_token,
|
|
323
|
+
...(tokens.refresh_token
|
|
324
|
+
? { refreshToken: tokens.refresh_token }
|
|
325
|
+
: { refreshToken: cred.refreshToken }),
|
|
326
|
+
expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in,
|
|
327
|
+
};
|
|
328
|
+
await writeConfig(
|
|
329
|
+
environment === 'production' && !config.profiles?.production
|
|
330
|
+
? { ...config, credential: refreshed }
|
|
331
|
+
: writeProfileConfig(config, environment, { credential: refreshed }),
|
|
332
|
+
);
|
|
333
|
+
return refreshed;
|
|
334
|
+
} catch {
|
|
335
|
+
return cred;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function resolveAgentName(configAgentName?: string): string | undefined {
|
|
340
|
+
return process.env.TOPOLO_AGENT_NAME ?? configAgentName;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function resolveServiceUrls(
|
|
344
|
+
configUrls?: Record<string, string>,
|
|
345
|
+
environment: TopoloEnvironment = selectedEnvironment(),
|
|
346
|
+
): Record<string, string> | undefined {
|
|
347
|
+
const merged: Record<string, string> = {
|
|
348
|
+
...ENVIRONMENT_DEFAULT_SERVICE_URLS[environment],
|
|
349
|
+
...(configUrls ?? {}),
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Env wins. Pull env-specific and global service URL vars that might be set.
|
|
353
|
+
const prefix = `${envVarPrefix(environment)}_SERVICE_URL_`;
|
|
354
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
355
|
+
if (k.startsWith(prefix) && v) {
|
|
356
|
+
merged[k.slice(prefix.length).toLowerCase()] = v;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
360
|
+
if (k.startsWith('TOPOLO_SERVICE_URL_') && v) {
|
|
361
|
+
merged[k.slice('TOPOLO_SERVICE_URL_'.length).toLowerCase()] = v;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function resolvedProfileConfig(
|
|
368
|
+
config: TopoloConfig,
|
|
369
|
+
environment: TopoloEnvironment = selectedEnvironment(),
|
|
370
|
+
): TopoloEnvironmentProfile {
|
|
371
|
+
const profile = profileForEnvironment(config, environment);
|
|
372
|
+
return {
|
|
373
|
+
credential: profile.credential,
|
|
374
|
+
oauthClientId: profile.oauthClientId,
|
|
375
|
+
agentName: profile.agentName,
|
|
376
|
+
serviceUrls: resolveServiceUrls(profile.serviceUrls, environment),
|
|
377
|
+
};
|
|
378
|
+
}
|
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,27 +73,27 @@ 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 appId of Object.keys(
|
|
96
|
+
for (const appId of Object.keys(DEFAULT_APP_URLS)) {
|
|
97
97
|
expect(applicationServices.has(appId)).toBe(true);
|
|
98
98
|
}
|
|
99
99
|
});
|
|
@@ -133,8 +133,8 @@ 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
140
|
for (const appId of application.services) {
|
|
@@ -159,7 +159,7 @@ describe('application catalog', () => {
|
|
|
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
164
|
APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-runtime')?.appId,
|
|
165
165
|
).toBeNull();
|
|
@@ -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
|
|
package/src/services.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* App API registry — base URLs for callable Topolo platform APIs.
|
|
3
3
|
*
|
|
4
4
|
* Source of truth lives in each app's `topolo.cloudcontrol.json`. The
|
|
5
|
-
* `
|
|
5
|
+
* `APP_API_URLS` map is regenerated from those files via
|
|
6
6
|
* `scripts/generate-services.mjs`; edit the JSON, not the generated file.
|
|
7
7
|
*
|
|
8
8
|
* Any entry can be overridden per-call via `TopoloClient` options, or globally
|
|
9
|
-
* via env vars like `
|
|
10
|
-
* (dots in
|
|
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
11
|
*
|
|
12
12
|
* IMPORTANT: The SDK NEVER accepts `orgId` as a parameter. Every request is
|
|
13
13
|
* scoped to the organization embedded in the auth credential (JWT claim or
|
|
14
14
|
* API-key binding). This is the load-bearing cross-org isolation guarantee.
|
|
15
15
|
*/
|
|
16
16
|
import {
|
|
17
|
-
|
|
18
|
-
type
|
|
17
|
+
APP_API_URLS,
|
|
18
|
+
type AppApiId,
|
|
19
19
|
} from './services.generated.js';
|
|
20
20
|
import {
|
|
21
21
|
APPLICATIONS,
|
|
@@ -40,12 +40,12 @@ export {
|
|
|
40
40
|
type ApplicationRequirementsAuditReport,
|
|
41
41
|
} from './application-requirements.js';
|
|
42
42
|
|
|
43
|
-
export {
|
|
44
|
-
export type {
|
|
43
|
+
export { APP_API_URLS };
|
|
44
|
+
export type { AppApiId };
|
|
45
45
|
export { APPLICATIONS };
|
|
46
46
|
export type { ApplicationCatalogEntry, ApplicationDeployTarget, ApplicationId };
|
|
47
47
|
|
|
48
|
-
export const
|
|
48
|
+
export const DEFAULT_APP_URLS: Record<AppApiId, string> = APP_API_URLS;
|
|
49
49
|
|
|
50
50
|
export interface ServiceCatalogUrlEntry {
|
|
51
51
|
appId?: string | null;
|
|
@@ -61,7 +61,7 @@ export function resolveCatalogServiceUrl(
|
|
|
61
61
|
entry: ServiceCatalogUrlEntry,
|
|
62
62
|
options: {
|
|
63
63
|
serviceKey?: string;
|
|
64
|
-
overrides?: Record<string, string> | Partial<Record<
|
|
64
|
+
overrides?: Record<string, string> | Partial<Record<AppApiId, string>>;
|
|
65
65
|
} = {},
|
|
66
66
|
): string | null {
|
|
67
67
|
const keys = catalogServiceKeyCandidates(entry, options.serviceKey);
|
|
@@ -75,13 +75,13 @@ export function resolveCatalogServiceUrl(
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export function resolveServiceUrl(
|
|
78
|
-
service:
|
|
79
|
-
overrides?: Partial<Record<
|
|
78
|
+
service: AppApiId,
|
|
79
|
+
overrides?: Partial<Record<AppApiId, string>>,
|
|
80
80
|
): string {
|
|
81
81
|
const override = resolveServiceUrlOverride([service], overrides);
|
|
82
82
|
if (override) return override;
|
|
83
83
|
|
|
84
|
-
return
|
|
84
|
+
return DEFAULT_APP_URLS[service];
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
function catalogServiceKeyCandidates(entry: ServiceCatalogUrlEntry, preferred?: string): string[] {
|
|
@@ -110,19 +110,19 @@ function serviceKeyCandidates(value: string): string[] {
|
|
|
110
110
|
|
|
111
111
|
function resolveServiceUrlOverride(
|
|
112
112
|
keys: string[],
|
|
113
|
-
overrides?: Record<string, string> | Partial<Record<
|
|
113
|
+
overrides?: Record<string, string> | Partial<Record<AppApiId, string>>,
|
|
114
114
|
): string | null {
|
|
115
115
|
for (const key of keys) {
|
|
116
|
-
const direct = overrides?.[key as
|
|
116
|
+
const direct = overrides?.[key as AppApiId];
|
|
117
117
|
if (direct) return direct;
|
|
118
118
|
|
|
119
119
|
const envStyleKey = key.replace(/\./g, '_');
|
|
120
|
-
const envStyle = overrides?.[envStyleKey as
|
|
120
|
+
const envStyle = overrides?.[envStyleKey as AppApiId];
|
|
121
121
|
if (envStyle) return envStyle;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
for (const key of keys) {
|
|
125
|
-
const envKey = `
|
|
125
|
+
const envKey = `TOPOLO_APP_URL_${key.replace(/\./g, '_').toUpperCase()}`;
|
|
126
126
|
const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
|
|
127
127
|
if (envValue) return envValue;
|
|
128
128
|
}
|