@topolo/sdk 0.6.0 → 0.8.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.
@@ -1,13 +1,36 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { TopoloClient } from './client.js';
3
- import { TopoloAuthError, TopoloHttpError, TopoloPermissionError } from './errors.js';
3
+ import {
4
+ TopoloAuthError,
5
+ TopoloConfirmationRequiredError,
6
+ TopoloHttpError,
7
+ TopoloPermissionError,
8
+ } from './errors.js';
4
9
 
5
10
  function stubFetch(responses: Array<{ status?: number; body?: unknown; contentType?: string }>) {
6
11
  const calls: Array<{ url: string; init: RequestInit }> = [];
7
12
  const impl = vi.fn(async (url: string, init: RequestInit) => {
8
13
  calls.push({ url, init });
9
14
  const next = responses.shift() ?? { status: 200, body: { ok: true } };
10
- return new Response(JSON.stringify(next.body ?? {}), {
15
+ let body = next.body ?? {};
16
+ const parsedUrl = new URL(url);
17
+ if (parsedUrl.pathname === '/api/services/catalog' && parsedUrl.searchParams.has('application')) {
18
+ const services = (body as { data?: { services?: unknown[] } }).data?.services;
19
+ if (Array.isArray(services)) body = { data: { application: services[0] } };
20
+ }
21
+ if (parsedUrl.pathname === '/api/actions/catalog' && parsedUrl.searchParams.has('actionId')) {
22
+ const actionId = parsedUrl.searchParams.get('actionId');
23
+ const actions = (body as { data?: { actions?: Array<Record<string, unknown>> } }).data?.actions;
24
+ if (Array.isArray(actions)) {
25
+ body = {
26
+ data: {
27
+ action: actions.find((action) =>
28
+ action.action_id === actionId || action.name === actionId || action.tool_name === actionId),
29
+ },
30
+ };
31
+ }
32
+ }
33
+ return new Response(JSON.stringify(body), {
11
34
  status: next.status ?? 200,
12
35
  headers: { 'Content-Type': next.contentType ?? 'application/json' },
13
36
  });
@@ -52,6 +75,7 @@ const ACTION_CATALOG = {
52
75
  path: '/api/mail/mailboxes/{mailboxId}/messages',
53
76
  permission_name: 'messages:read',
54
77
  required_permission: 'app_mail.messages:read',
78
+ authorization_mode: 'runtime',
55
79
  input_schema: { type: 'object' },
56
80
  output_schema: {},
57
81
  read_only: true,
@@ -186,7 +210,7 @@ describe('TopoloClient.request', () => {
186
210
  });
187
211
  await expect(
188
212
  client.request({ service: 'mail', method: 'POST', path: '/api/mail/mailboxes/primary/messages', body: {} }),
189
- ).rejects.toBeInstanceOf(TopoloAuthError);
213
+ ).rejects.toBeInstanceOf(TopoloConfirmationRequiredError);
190
214
  });
191
215
 
192
216
  it('allows mutating requests with confirm: true', async () => {
@@ -222,6 +246,76 @@ describe('TopoloClient.request', () => {
222
246
  });
223
247
  });
224
248
 
249
+ it('refreshes a rejected access token and retries the request once', async () => {
250
+ const { impl, calls } = stubFetch([
251
+ { status: 401, body: { error: { message: 'Expired' } } },
252
+ { body: { ok: true } },
253
+ ]);
254
+ const refreshCredential = vi.fn(async () => ({
255
+ kind: 'access_token' as const,
256
+ accessToken: 'fresh',
257
+ }));
258
+ const client = new TopoloClient({
259
+ credential: { kind: 'access_token', accessToken: 'stale' },
260
+ agent: AGENT,
261
+ fetch: impl,
262
+ refreshCredential,
263
+ });
264
+
265
+ await expect(client.request({ service: 'auth', path: '/api/me' })).resolves.toEqual({ ok: true });
266
+ expect(refreshCredential).toHaveBeenCalledTimes(1);
267
+ expect(requestCalls(calls)).toHaveLength(2);
268
+ expect(new Headers(requestCalls(calls)[0]!.init.headers).get('Authorization')).toBe('Bearer stale');
269
+ expect(new Headers(requestCalls(calls)[1]!.init.headers).get('Authorization')).toBe('Bearer fresh');
270
+ });
271
+
272
+ it('shares one refresh across concurrent rejected requests', async () => {
273
+ let resolveRefresh!: (credential: { kind: 'access_token'; accessToken: string }) => void;
274
+ const refreshCredential = vi.fn(() => new Promise<{ kind: 'access_token'; accessToken: string }>((resolve) => {
275
+ resolveRefresh = resolve;
276
+ }));
277
+ const impl = vi.fn(async (_url: string, init: RequestInit) => {
278
+ const authorization = new Headers(init.headers).get('Authorization');
279
+ return new Response(JSON.stringify(authorization === 'Bearer fresh'
280
+ ? { ok: true }
281
+ : { error: { message: 'Expired' } }), {
282
+ status: authorization === 'Bearer fresh' ? 200 : 401,
283
+ headers: { 'Content-Type': 'application/json' },
284
+ });
285
+ }) as unknown as typeof fetch;
286
+ const client = new TopoloClient({
287
+ credential: { kind: 'access_token', accessToken: 'stale' },
288
+ agent: AGENT,
289
+ fetch: impl,
290
+ refreshCredential,
291
+ });
292
+
293
+ const requests = Promise.all([
294
+ client.request({ service: 'auth', path: '/api/me' }),
295
+ client.request({ service: 'auth', path: '/api/me' }),
296
+ ]);
297
+ await vi.waitFor(() => expect(refreshCredential).toHaveBeenCalledTimes(1));
298
+ resolveRefresh({ kind: 'access_token', accessToken: 'fresh' });
299
+ await expect(requests).resolves.toEqual([{ ok: true }, { ok: true }]);
300
+ expect(refreshCredential).toHaveBeenCalledTimes(1);
301
+ });
302
+
303
+ it('never invokes token refresh for an API key 401', async () => {
304
+ const { impl } = stubFetch([
305
+ { status: 401, body: { error: { message: 'Rejected' } } },
306
+ ]);
307
+ const refreshCredential = vi.fn();
308
+ const client = new TopoloClient({
309
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
310
+ agent: AGENT,
311
+ fetch: impl,
312
+ refreshCredential,
313
+ });
314
+
315
+ await expect(client.request({ service: 'auth', path: '/api/me' })).rejects.toThrow('Rejected');
316
+ expect(refreshCredential).not.toHaveBeenCalled();
317
+ });
318
+
225
319
  it('regression: nested error.message object never stringifies to [object Object]', async () => {
226
320
  const { impl } = stubFetch([
227
321
  { status: 401, body: { error: { code: 'bad_sig', message: 'Bad signature' } } },
@@ -476,6 +570,52 @@ describe('TopoloClient.getAppIdBySlug', () => {
476
570
  });
477
571
 
478
572
  describe('TopoloClient.listServices', () => {
573
+ it('supports bounded top-level service search and exact application lookup', async () => {
574
+ const minimal = {
575
+ id: 'app_minimal',
576
+ name: 'Minimal App',
577
+ apiBaseUrl: 'https://minimal-api.example',
578
+ launchUrl: 'https://minimal.example',
579
+ launchable: true,
580
+ };
581
+ const { impl, calls } = stubFetch([
582
+ { body: { services: [null, minimal], page: { nextCursor: 'cursor-2', hasMore: true } } },
583
+ { body: { application: minimal } },
584
+ ]);
585
+ const client = new TopoloClient({
586
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
587
+ agent: AGENT,
588
+ fetch: impl,
589
+ serviceUrls: { developers: 'https://developers.example' },
590
+ });
591
+
592
+ const page = await client.searchServices({ query: ' minimal ', cursor: ' cursor-1 ', limit: 2.9 });
593
+ expect(page).toMatchObject({
594
+ services: [{ appId: 'app_minimal', name: 'Minimal App', apiBaseUrl: 'https://minimal-api.example' }],
595
+ page: { nextCursor: 'cursor-2', hasMore: true },
596
+ });
597
+ expect(calls[0]!.url).toBe('https://developers.example/api/services/catalog?query=minimal&cursor=cursor-1&limit=2');
598
+
599
+ await expect(client.getService(' ')).rejects.toMatchObject({ code: 'unknown_service' });
600
+ await expect(client.getService(' app_minimal ')).resolves.toMatchObject({ appId: 'app_minimal' });
601
+ expect(calls[1]!.url).toBe('https://developers.example/api/services/catalog?application=app_minimal');
602
+ });
603
+
604
+ it('rejects malformed service catalog and exact lookup responses', async () => {
605
+ const { impl } = stubFetch([
606
+ { body: { data: {} } },
607
+ { body: { data: { application: null } } },
608
+ ]);
609
+ const client = new TopoloClient({
610
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
611
+ agent: AGENT,
612
+ fetch: impl,
613
+ serviceUrls: { developers: 'https://developers.example' },
614
+ });
615
+ await expect(client.searchServices()).rejects.toMatchObject({ code: 'service_catalog_unavailable' });
616
+ await expect(client.getService('missing')).rejects.toMatchObject({ code: 'unknown_service' });
617
+ });
618
+
479
619
  it('normalizes the live catalog and attaches the credential', async () => {
480
620
  const { impl, calls } = stubFetch([
481
621
  {
@@ -610,7 +750,7 @@ describe('TopoloClient.listServices', () => {
610
750
 
611
751
  await client.request({ service: 'mail', path: '/api/health' });
612
752
 
613
- expect(calls[0]!.url).toBe('https://auth.example.com/api/services/catalog');
753
+ expect(calls[0]!.url).toBe('https://auth.example.com/api/services/catalog?application=mail');
614
754
  expect(calls[1]!.url).toBe('https://mail-api.topolo.app/api/health');
615
755
  });
616
756
 
@@ -645,7 +785,7 @@ describe('TopoloClient.listServices', () => {
645
785
 
646
786
  await client.request({ service: 'crm', path: '/health' });
647
787
 
648
- expect(calls[0]!.url).toBe('https://auth.example.com/api/services/catalog');
788
+ expect(calls[0]!.url).toBe('https://auth.example.com/api/services/catalog?application=crm');
649
789
  expect(calls[1]!.url).toBe('https://crm-api.topolo.app/health');
650
790
  });
651
791
 
@@ -765,27 +905,29 @@ describe('TopoloClient.listServices', () => {
765
905
  });
766
906
  });
767
907
 
768
- it('caches the live catalog per client for repeated dynamic service calls', async () => {
769
- const { impl, calls } = stubFetch([
770
- {
771
- body: {
772
- data: {
773
- services: [
774
- {
775
- app_id: 'app_future',
776
- app_slug: 'topolo-warehouse',
777
- name: 'Warehouse',
778
- api_base_url: 'https://warehouse.topolo.app',
779
- launch_url: 'https://warehouse.topolo.app',
780
- surface_kind: 'api',
781
- status: 'active',
782
- granted_permissions: ['app_future.read:all'],
783
- },
784
- ],
785
- },
908
+ it('uses bounded exact catalog resolution for repeated dynamic service calls', async () => {
909
+ const warehouseCatalog = {
910
+ body: {
911
+ data: {
912
+ services: [
913
+ {
914
+ app_id: 'app_future',
915
+ app_slug: 'topolo-warehouse',
916
+ name: 'Warehouse',
917
+ api_base_url: 'https://warehouse.topolo.app',
918
+ launch_url: 'https://warehouse.topolo.app',
919
+ surface_kind: 'api',
920
+ status: 'active',
921
+ granted_permissions: ['app_future.read:all'],
922
+ },
923
+ ],
786
924
  },
787
925
  },
926
+ };
927
+ const { impl, calls } = stubFetch([
928
+ warehouseCatalog,
788
929
  { body: { ok: true } },
930
+ warehouseCatalog,
789
931
  { body: { ok: true } },
790
932
  ]);
791
933
  const client = new TopoloClient({
@@ -799,14 +941,61 @@ describe('TopoloClient.listServices', () => {
799
941
  await client.request({ service: 'warehouse', path: '/api/two' });
800
942
 
801
943
  expect(calls.map((call) => call.url)).toEqual([
802
- 'https://auth.example.com/api/services/catalog',
944
+ 'https://auth.example.com/api/services/catalog?application=warehouse',
803
945
  'https://warehouse.topolo.app/api/one',
946
+ 'https://auth.example.com/api/services/catalog?application=warehouse',
804
947
  'https://warehouse.topolo.app/api/two',
805
948
  ]);
806
949
  });
807
950
  });
808
951
 
809
952
  describe('TopoloClient action catalog', () => {
953
+ it('supports bounded top-level action search and rejects missing service bounds', async () => {
954
+ const action = structuredClone(ACTION_CATALOG.body.data.actions[0]);
955
+ const { impl, calls } = stubFetch([
956
+ { body: { actions: [null, action], page: { nextCursor: 'next-action', hasMore: true } } },
957
+ ]);
958
+ const client = new TopoloClient({
959
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
960
+ agent: AGENT,
961
+ fetch: impl,
962
+ serviceUrls: { developers: 'https://developers.example' },
963
+ });
964
+
965
+ await expect(client.searchActions({ service: ' ' })).rejects.toMatchObject({ code: 'service_required' });
966
+ await expect(client.listActions()).rejects.toMatchObject({ code: 'service_required' });
967
+ const page = await client.searchActions({
968
+ service: 'topolo-mail',
969
+ query: ' messages ',
970
+ cursor: ' action-1 ',
971
+ limit: 5,
972
+ });
973
+ expect(page.actions).toHaveLength(1);
974
+ expect(page.actions[0]?.authorizationMode).toBe('runtime');
975
+ expect(page.page).toEqual({ nextCursor: 'next-action', hasMore: true });
976
+ expect(calls[0]!.url).toBe(
977
+ 'https://developers.example/api/actions/catalog?service=topolo-mail&query=messages&cursor=action-1&limit=5',
978
+ );
979
+ });
980
+
981
+ it('rejects malformed action searches and exact action lookups', async () => {
982
+ const { impl } = stubFetch([
983
+ { body: {} },
984
+ { body: { action: null } },
985
+ ]);
986
+ const client = new TopoloClient({
987
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
988
+ agent: AGENT,
989
+ fetch: impl,
990
+ serviceUrls: { developers: 'https://developers.example' },
991
+ });
992
+ await expect(client.searchActions({ service: 'topolo-mail' })).rejects.toMatchObject({
993
+ code: 'action_catalog_unavailable',
994
+ });
995
+ await expect(client.getAction(' ')).rejects.toMatchObject({ code: 'unknown_action' });
996
+ await expect(client.getAction('missing.action')).rejects.toMatchObject({ code: 'unknown_action' });
997
+ });
998
+
810
999
  it('lists scoped actions from Auth', async () => {
811
1000
  const { impl, calls } = stubFetch([ACTION_CATALOG]);
812
1001
  const client = new TopoloClient({
@@ -815,13 +1004,13 @@ describe('TopoloClient action catalog', () => {
815
1004
  fetch: impl,
816
1005
  serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
817
1006
  });
818
- const actions = await client.listActions();
1007
+ const actions = await client.listActions({ service: 'topolo-mail' });
819
1008
  expect(actions.map((action) => action.name)).toEqual([
820
1009
  'messages.list',
821
1010
  'messages.get',
822
1011
  'messages.send',
823
1012
  ]);
824
- expect(calls[0]!.url).toBe('https://auth.example.com/api/actions/catalog');
1013
+ expect(calls[0]!.url).toBe('https://auth.example.com/api/actions/catalog?service=topolo-mail');
825
1014
  });
826
1015
 
827
1016
  it('calls GET actions by mapping remaining input to query params', async () => {
@@ -832,7 +1021,7 @@ describe('TopoloClient action catalog', () => {
832
1021
  fetch: impl,
833
1022
  serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
834
1023
  });
835
- await client.callAction('messages.list', { mailboxId: 'primary', q: 'Ada', page: 2 });
1024
+ await client.callAction('app_mail.messages.list', { mailboxId: 'primary', q: 'Ada', page: 2 });
836
1025
  const call = requestCalls(calls)[0]!;
837
1026
  const url = new URL(call.url);
838
1027
  expect(url.toString()).toBe('https://mail-api.topolo.app/api/mail/mailboxes/primary/messages?q=Ada&page=2');
@@ -842,6 +1031,47 @@ describe('TopoloClient action catalog', () => {
842
1031
  expect(headers.get('X-Topolo-Action-Name')).toBe('messages.list');
843
1032
  });
844
1033
 
1034
+ it('forwards resource context when calling an action', async () => {
1035
+ const { impl, calls } = stubFetch([ACTION_CATALOG, MAIL_CATALOG, { body: { messages: [] } }]);
1036
+ const client = new TopoloClient({
1037
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
1038
+ agent: AGENT,
1039
+ fetch: impl,
1040
+ serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
1041
+ });
1042
+
1043
+ await client.callAction(
1044
+ 'app_mail.messages.list',
1045
+ { mailboxId: 'primary' },
1046
+ { resource: { resourceType: 'workspace', resourceId: 'workspace_123' } },
1047
+ );
1048
+
1049
+ const headers = new Headers(requestCalls(calls)[0]!.init.headers);
1050
+ expect(headers.get('X-Topolo-Resource-Type')).toBe('workspace');
1051
+ expect(headers.get('X-Topolo-Resource-ID')).toBe('workspace_123');
1052
+ });
1053
+
1054
+ it('calls the action target origin without an app-level service lookup', async () => {
1055
+ const targetedCatalog = structuredClone(ACTION_CATALOG);
1056
+ const first = targetedCatalog.body.data.actions[0] as Record<string, unknown>;
1057
+ first.target_key = 'api';
1058
+ first.api_base_url = 'https://mail-actions.example';
1059
+ const { impl, calls } = stubFetch([targetedCatalog, { body: { messages: [] } }]);
1060
+ const client = new TopoloClient({
1061
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
1062
+ agent: AGENT,
1063
+ fetch: impl,
1064
+ serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
1065
+ });
1066
+
1067
+ await client.callAction('app_mail.messages.list', { mailboxId: 'primary' });
1068
+
1069
+ expect(calls.map((call) => call.url)).toEqual([
1070
+ 'https://auth.example.com/api/actions/catalog?actionId=app_mail.messages.list',
1071
+ 'https://mail-actions.example/api/mail/mailboxes/primary/messages',
1072
+ ]);
1073
+ });
1074
+
845
1075
  it('substitutes action path params and removes them from the query', async () => {
846
1076
  const { impl, calls } = stubFetch([ACTION_CATALOG, MAIL_CATALOG, { body: { id: 'm1' } }]);
847
1077
  const client = new TopoloClient({
@@ -850,7 +1080,7 @@ describe('TopoloClient action catalog', () => {
850
1080
  fetch: impl,
851
1081
  serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
852
1082
  });
853
- await client.callAction('topolo_mail_messages_get', {
1083
+ await client.callAction('app_mail.messages.get', {
854
1084
  mailboxId: 'primary',
855
1085
  messageId: 'm 1',
856
1086
  includeDeleted: false,
@@ -871,8 +1101,41 @@ describe('TopoloClient action catalog', () => {
871
1101
  serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
872
1102
  });
873
1103
  await expect(
874
- client.callAction('messages.send', { mailboxId: 'primary', subject: 'Hello' }),
875
- ).rejects.toBeInstanceOf(TopoloAuthError);
1104
+ client.callAction('app_mail.messages.send', { mailboxId: 'primary', subject: 'Hello' }),
1105
+ ).rejects.toBeInstanceOf(TopoloConfirmationRequiredError);
1106
+ });
1107
+
1108
+ it('enforces confirmation metadata even on a GET action', async () => {
1109
+ const catalog = structuredClone(ACTION_CATALOG);
1110
+ const action = catalog.body.data.actions[0]!;
1111
+ action.requires_confirmation = true;
1112
+ (action as Record<string, unknown>).agent_access = 'confirm';
1113
+ const { impl } = stubFetch([catalog]);
1114
+ const client = new TopoloClient({
1115
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
1116
+ agent: AGENT,
1117
+ fetch: impl,
1118
+ serviceUrls: { developers: 'https://developers.example' },
1119
+ });
1120
+
1121
+ await expect(client.callAction(action.action_id, { mailboxId: 'primary' }))
1122
+ .rejects.toBeInstanceOf(TopoloConfirmationRequiredError);
1123
+ });
1124
+
1125
+ it('refuses actions whose developer config disables agent access', async () => {
1126
+ const catalog = structuredClone(ACTION_CATALOG);
1127
+ const action = catalog.body.data.actions[0]!;
1128
+ (action as Record<string, unknown>).agent_access = 'off';
1129
+ const { impl } = stubFetch([catalog]);
1130
+ const client = new TopoloClient({
1131
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
1132
+ agent: AGENT,
1133
+ fetch: impl,
1134
+ serviceUrls: { developers: 'https://developers.example' },
1135
+ });
1136
+
1137
+ await expect(client.callAction(action.action_id, { mailboxId: 'primary' }, { confirm: true }))
1138
+ .rejects.toBeInstanceOf(TopoloPermissionError);
876
1139
  });
877
1140
 
878
1141
  it('sends remaining input as the body when a mutating action is confirmed', async () => {
@@ -883,7 +1146,7 @@ describe('TopoloClient action catalog', () => {
883
1146
  fetch: impl,
884
1147
  serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
885
1148
  });
886
- await client.callAction('messages.send', { mailboxId: 'primary', subject: 'Hello' }, { confirm: true });
1149
+ await client.callAction('app_mail.messages.send', { mailboxId: 'primary', subject: 'Hello' }, { confirm: true });
887
1150
  const call = requestCalls(calls)[0]!;
888
1151
  expect(call.init.method).toBe('POST');
889
1152
  expect(JSON.parse(String(call.init.body))).toEqual({ subject: 'Hello' });