@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.
- package/README.md +11 -3
- package/dist/index.cjs +187 -53
- package/dist/index.d.cts +52 -8
- package/dist/index.d.ts +52 -8
- package/dist/index.js +186 -53
- package/package.json +1 -2
- package/src/applications.generated.ts +1 -0
- package/src/client.test.ts +294 -31
- package/src/client.ts +280 -59
- package/src/errors.ts +7 -0
- package/src/index.ts +1 -0
- package/src/modules/crm.test.ts +1 -3
- package/src/modules/p2p.test.ts +1 -3
- package/src/node/credential-store.test.ts +337 -0
- package/src/node/credential-store.ts +86 -5
- package/dist/node/credential-store.cjs +0 -440
- package/dist/node/credential-store.d.cts +0 -81
- package/dist/node/credential-store.d.ts +0 -81
- package/dist/node/credential-store.js +0 -399
package/src/client.test.ts
CHANGED
|
@@ -1,13 +1,36 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { TopoloClient } from './client.js';
|
|
3
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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('
|
|
769
|
-
const
|
|
770
|
-
{
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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('
|
|
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(
|
|
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' });
|