@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/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
- DEFAULT_SERVICE_URLS,
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 ServiceId,
35
+ type AppApiId,
36
36
  } from "./services.js";
37
37
  export {
38
38
  TopoloSdkError,
@@ -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: { service_id: 'service_crm' } }), {
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
- service_id: 'srv_crm',
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: ['srv_crm.contacts:read'],
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.clearServiceIdCache();
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: { service_id: 'service_auth' } }), {
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
- serviceIds: { auth: 'service_auth' },
51
+ appIds: { auth: 'service_auth' },
52
52
  });
53
53
  }
54
54
 
55
55
  beforeEach(() => {
56
- TopoloClient.clearServiceIdCache();
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: 'svc_1', email: 'svc@example.com' },
96
+ user: { id: 'usr_1', email: 'user@example.com' },
97
97
  organization: { id: 'org_2', slug: 'beta' },
98
98
  },
99
99
  },
@@ -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: { service_id: 'service_p2p' } }), {
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
- service_id: 'srv_p2p',
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: ['srv_p2p.directory:read'],
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.clearServiceIdCache();
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',
@@ -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 ServiceId } from './services.js';
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<ServiceId, string>>;
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 SERVICES = {
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 ServiceId = keyof typeof SERVICES;
55
+ export type AppApiId = keyof typeof APP_API_URLS;
@@ -3,7 +3,7 @@ import {
3
3
  APPLICATION_REQUIREMENTS,
4
4
  APPLICATION_REQUIREMENTS_VERSION,
5
5
  APPLICATIONS,
6
- DEFAULT_SERVICE_URLS,
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('service registry', () => {
65
+ describe('app API registry', () => {
66
66
  it('includes every production worker API that belongs to a platform application', () => {
67
- expect(DEFAULT_SERVICE_URLS).toMatchObject({
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(DEFAULT_SERVICE_URLS).toMatchObject({
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(DEFAULT_SERVICE_URLS).not.toHaveProperty('web.api');
82
- expect(DEFAULT_SERVICE_URLS).not.toHaveProperty('web.runtime');
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 service ids into the generated registry', async () => {
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('PLATFORM_SERVICE_IDS');
88
+ expect(services).not.toHaveProperty('PLATFORM_APP_IDS');
89
89
  });
90
90
 
91
- it('maps every callable service back to an application catalog entry', () => {
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 serviceId of Object.keys(DEFAULT_SERVICE_URLS)) {
97
- expect(applicationServices.has(serviceId)).toBe(true);
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
- serviceId: 'srv_crm',
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
- serviceId: 'srv_crm',
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
- serviceId: 'srv_future',
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 service IDs from application metadata', () => {
137
- const registeredServices = new Set<string>(Object.keys(DEFAULT_SERVICE_URLS));
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 serviceId of application.services) {
141
- expect(registeredServices.has(serviceId)).toBe(true);
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.serviceId) expect(registeredServices.has(target.serviceId)).toBe(true);
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')?.serviceId,
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')?.serviceId,
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 service.
162
+ // agent-callable app API.
163
163
  expect(
164
- APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-runtime')?.serviceId,
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.serviceId)
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')?.serviceId,
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')?.serviceId,
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')?.serviceId,
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('service-registration-and-scopes');
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('service-registration-and-scopes')?.status).toBe('partial');
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