@topolo/sdk 0.4.7 → 0.4.9
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/dist/index.cjs +163 -137
- package/dist/index.d.cts +83 -91
- package/dist/index.d.ts +83 -91
- package/dist/index.js +163 -137
- package/package.json +1 -1
- package/src/application-requirements.ts +9 -5
- package/src/applications.generated.ts +50 -102
- package/src/client.test.ts +108 -19
- package/src/client.ts +137 -4
- package/src/index.test.ts +69 -0
- package/src/index.ts +1 -0
- package/src/modules/crm.test.ts +208 -0
- package/src/modules/identity.test.ts +165 -0
- package/src/modules/p2p.test.ts +286 -0
- package/src/oauth.test.ts +103 -0
- package/src/services.generated.ts +14 -15
- package/src/services.test.ts +18 -7
- package/src/widgets.test.ts +216 -0
package/src/client.ts
CHANGED
|
@@ -76,6 +76,11 @@ export interface RequestOptions {
|
|
|
76
76
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
77
77
|
query?: Record<string, string | number | boolean | undefined>;
|
|
78
78
|
body?: unknown;
|
|
79
|
+
/** Workspace-boundary resource context for resource-scoped API keys. */
|
|
80
|
+
resource?: {
|
|
81
|
+
resourceType: string;
|
|
82
|
+
resourceId: string;
|
|
83
|
+
};
|
|
79
84
|
/** Explicit write-acknowledgement for mutating calls. */
|
|
80
85
|
confirm?: boolean;
|
|
81
86
|
/** Extra headers appended after auth/audit headers. */
|
|
@@ -86,6 +91,8 @@ export interface RequestOptions {
|
|
|
86
91
|
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
87
92
|
const TOPOLO_ACTION_ID_HEADER = 'X-Topolo-Action-Id';
|
|
88
93
|
const TOPOLO_ACTION_NAME_HEADER = 'X-Topolo-Action-Name';
|
|
94
|
+
const TOPOLO_RESOURCE_TYPE_HEADER = 'X-Topolo-Resource-Type';
|
|
95
|
+
const TOPOLO_RESOURCE_ID_HEADER = 'X-Topolo-Resource-ID';
|
|
89
96
|
|
|
90
97
|
const POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
91
98
|
const NEGATIVE_CACHE_TTL_MS = 60 * 1000; // 1 minute
|
|
@@ -112,6 +119,18 @@ export interface TopoloServiceCatalogEntry {
|
|
|
112
119
|
surfaceKind: string | null;
|
|
113
120
|
launchable: boolean;
|
|
114
121
|
permissions: string[];
|
|
122
|
+
apiKeyResource: TopoloApiKeyResourceMetadata | null;
|
|
123
|
+
apiKeyResources: TopoloApiKeyResourceMetadata[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface TopoloApiKeyResourceMetadata {
|
|
127
|
+
resourceType: string;
|
|
128
|
+
label: string;
|
|
129
|
+
emptyLabel: string;
|
|
130
|
+
resourceConcept: 'workspace';
|
|
131
|
+
canonicalResourceType: 'workspace';
|
|
132
|
+
defaultEntitlement: 'enterprise';
|
|
133
|
+
exceptionPolicy: 'app_or_organization';
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
export interface TopoloActionCatalogEntry {
|
|
@@ -222,6 +241,18 @@ export class TopoloClient {
|
|
|
222
241
|
if (opts.headers) {
|
|
223
242
|
for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
|
|
224
243
|
}
|
|
244
|
+
if (opts.resource) {
|
|
245
|
+
const resourceType = opts.resource.resourceType.trim();
|
|
246
|
+
const resourceId = opts.resource.resourceId.trim();
|
|
247
|
+
if (!resourceType || !resourceId) {
|
|
248
|
+
throw new TopoloSdkError(
|
|
249
|
+
'invalid_resource_context',
|
|
250
|
+
'Both resource.resourceType and resource.resourceId are required when resource context is provided.',
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
headers.set(TOPOLO_RESOURCE_TYPE_HEADER, resourceType);
|
|
254
|
+
headers.set(TOPOLO_RESOURCE_ID_HEADER, resourceId);
|
|
255
|
+
}
|
|
225
256
|
|
|
226
257
|
const controller = new AbortController();
|
|
227
258
|
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
@@ -306,10 +337,20 @@ export class TopoloClient {
|
|
|
306
337
|
throw new TopoloSdkError('unknown_service', 'service is required');
|
|
307
338
|
}
|
|
308
339
|
|
|
309
|
-
|
|
340
|
+
// auth + developers are DIRECT services resolved without a catalog lookup:
|
|
341
|
+
// developers serves the catalog itself, so resolving it via the catalog would
|
|
342
|
+
// recurse infinitely.
|
|
343
|
+
if (serviceKey === 'auth' || serviceKey === 'developers') {
|
|
310
344
|
const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
|
|
311
345
|
if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
|
|
312
|
-
|
|
346
|
+
// The catalog host (developers) defaults to the SAME environment as auth.
|
|
347
|
+
// Apps configure only `auth` per-env; derive developers from it (auth.X ->
|
|
348
|
+
// developers.X) so the catalog cutover needs no per-app config change.
|
|
349
|
+
if (serviceKey === 'developers') {
|
|
350
|
+
const derived = deriveDevelopersFromAuth(resolveConfiguredServiceUrl('auth', this.serviceUrls));
|
|
351
|
+
if (derived) return { serviceKey, baseUrl: derived };
|
|
352
|
+
}
|
|
353
|
+
return { serviceKey, baseUrl: DEFAULT_SERVICE_URLS[serviceKey as 'auth' | 'developers'] };
|
|
313
354
|
}
|
|
314
355
|
|
|
315
356
|
const live = await this.resolveLiveCallableService(serviceKey);
|
|
@@ -355,7 +396,8 @@ export class TopoloClient {
|
|
|
355
396
|
const configuredServiceId = resolveConfiguredServiceId(normalized, this.serviceIds);
|
|
356
397
|
if (configuredServiceId) return configuredServiceId;
|
|
357
398
|
|
|
358
|
-
|
|
399
|
+
// app-catalog greenfield: by-slug resolver lives on the Catalog (developers).
|
|
400
|
+
const authBase = resolveServiceUrl('developers', this.serviceUrls);
|
|
359
401
|
const cacheKey = `${authBase}|${normalized}`;
|
|
360
402
|
const now = Date.now();
|
|
361
403
|
const cached = TopoloClient.serviceIdCache.get(cacheKey);
|
|
@@ -419,10 +461,13 @@ export class TopoloClient {
|
|
|
419
461
|
return this.serviceCatalogCache.entries;
|
|
420
462
|
}
|
|
421
463
|
|
|
464
|
+
// app-catalog greenfield: the catalog moved to the Catalog (developers)
|
|
465
|
+
// service. Auth no longer owns the registry; it only provides scoping, which
|
|
466
|
+
// the Catalog composes server-side. Consumers pass the per-env developers URL.
|
|
422
467
|
const res = await this.request<
|
|
423
468
|
| { data?: { services?: unknown[] } }
|
|
424
469
|
| { services?: unknown[] }
|
|
425
|
-
>({ service: '
|
|
470
|
+
>({ service: 'developers', method: 'GET', path: '/api/services/catalog' });
|
|
426
471
|
const raw =
|
|
427
472
|
(res as { data?: { services?: unknown[] } }).data?.services ??
|
|
428
473
|
(res as { services?: unknown[] }).services;
|
|
@@ -447,6 +492,18 @@ export class TopoloClient {
|
|
|
447
492
|
? s.granted_permissions
|
|
448
493
|
.filter((permission): permission is string => typeof permission === 'string' && permission.trim().length > 0)
|
|
449
494
|
: [];
|
|
495
|
+
const catalogApiKeyResources = normalizeApiKeyResourceMetadataList(
|
|
496
|
+
s.api_key_resources ?? s.apiKeyResources,
|
|
497
|
+
);
|
|
498
|
+
const apiKeyResource =
|
|
499
|
+
normalizeApiKeyResourceMetadata(s.api_key_resource ?? s.apiKeyResource) ??
|
|
500
|
+
catalogApiKeyResources[0] ??
|
|
501
|
+
null;
|
|
502
|
+
const apiKeyResources = catalogApiKeyResources.length > 0
|
|
503
|
+
? catalogApiKeyResources
|
|
504
|
+
: apiKeyResource
|
|
505
|
+
? [apiKeyResource]
|
|
506
|
+
: [];
|
|
450
507
|
entries.push({
|
|
451
508
|
serviceId,
|
|
452
509
|
slug: typeof s.app_slug === 'string' ? s.app_slug : null,
|
|
@@ -470,6 +527,8 @@ export class TopoloClient {
|
|
|
470
527
|
typeof s.surface_kind === 'string' ? s.surface_kind : null,
|
|
471
528
|
launchable: s.launchable === true,
|
|
472
529
|
permissions,
|
|
530
|
+
apiKeyResource,
|
|
531
|
+
apiKeyResources,
|
|
473
532
|
});
|
|
474
533
|
}
|
|
475
534
|
this.serviceCatalogCache = {
|
|
@@ -577,6 +636,8 @@ export class TopoloClient {
|
|
|
577
636
|
surfaceKind: 'application',
|
|
578
637
|
launchable: Boolean(app.productionUrl),
|
|
579
638
|
permissions: [],
|
|
639
|
+
apiKeyResource: null,
|
|
640
|
+
apiKeyResources: [],
|
|
580
641
|
}));
|
|
581
642
|
}
|
|
582
643
|
|
|
@@ -640,6 +701,60 @@ function resolveConfiguredServiceId(
|
|
|
640
701
|
return null;
|
|
641
702
|
}
|
|
642
703
|
|
|
704
|
+
function normalizeApiKeyResourceMetadata(input: unknown): TopoloApiKeyResourceMetadata | null {
|
|
705
|
+
if (!isRecord(input)) return null;
|
|
706
|
+
const resourceType = readString(input.resourceType ?? input.resource_type);
|
|
707
|
+
const label = readString(input.label);
|
|
708
|
+
const emptyLabel = readString(input.emptyLabel ?? input.empty_label);
|
|
709
|
+
const resourceConcept = readString(input.resourceConcept ?? input.resource_concept);
|
|
710
|
+
const canonicalResourceType = readString(input.canonicalResourceType ?? input.canonical_resource_type);
|
|
711
|
+
const defaultEntitlement = readString(input.defaultEntitlement ?? input.default_entitlement);
|
|
712
|
+
const exceptionPolicy = readString(input.exceptionPolicy ?? input.exception_policy);
|
|
713
|
+
|
|
714
|
+
if (
|
|
715
|
+
!resourceType ||
|
|
716
|
+
!label ||
|
|
717
|
+
!emptyLabel ||
|
|
718
|
+
resourceConcept !== 'workspace' ||
|
|
719
|
+
canonicalResourceType !== 'workspace' ||
|
|
720
|
+
defaultEntitlement !== 'enterprise' ||
|
|
721
|
+
exceptionPolicy !== 'app_or_organization'
|
|
722
|
+
) {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
resourceType,
|
|
728
|
+
label,
|
|
729
|
+
emptyLabel,
|
|
730
|
+
resourceConcept,
|
|
731
|
+
canonicalResourceType,
|
|
732
|
+
defaultEntitlement,
|
|
733
|
+
exceptionPolicy,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function normalizeApiKeyResourceMetadataList(input: unknown): TopoloApiKeyResourceMetadata[] {
|
|
738
|
+
if (!Array.isArray(input)) return [];
|
|
739
|
+
const resources: TopoloApiKeyResourceMetadata[] = [];
|
|
740
|
+
const seenTypes = new Set<string>();
|
|
741
|
+
for (const item of input) {
|
|
742
|
+
const resource = normalizeApiKeyResourceMetadata(item);
|
|
743
|
+
if (!resource || seenTypes.has(resource.resourceType)) continue;
|
|
744
|
+
seenTypes.add(resource.resourceType);
|
|
745
|
+
resources.push(resource);
|
|
746
|
+
}
|
|
747
|
+
return resources;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function readString(value: unknown): string {
|
|
751
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
755
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
756
|
+
}
|
|
757
|
+
|
|
643
758
|
function resolveConfiguredServiceUrl(
|
|
644
759
|
slug: string,
|
|
645
760
|
serviceUrls?: Record<string, string>,
|
|
@@ -673,6 +788,24 @@ function normalizeLookupKey(value: string): string {
|
|
|
673
788
|
return String(value || '').trim().toLowerCase().replace(/\s+/g, '-');
|
|
674
789
|
}
|
|
675
790
|
|
|
791
|
+
/**
|
|
792
|
+
* Derive the Catalog (developers) base URL from a configured auth base URL by
|
|
793
|
+
* swapping the leading `auth` host label for `developers` (auth.stg.topolo.us ->
|
|
794
|
+
* developers.stg.topolo.us). Returns null if the auth URL is absent or doesn't
|
|
795
|
+
* start with the `auth.` label, so the caller falls back to the bundled default.
|
|
796
|
+
*/
|
|
797
|
+
function deriveDevelopersFromAuth(authUrl: string | null | undefined): string | null {
|
|
798
|
+
if (!authUrl) return null;
|
|
799
|
+
try {
|
|
800
|
+
const url = new URL(authUrl);
|
|
801
|
+
if (!/^auth\./i.test(url.hostname)) return null;
|
|
802
|
+
url.hostname = url.hostname.replace(/^auth\./i, 'developers.');
|
|
803
|
+
return url.origin;
|
|
804
|
+
} catch {
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
676
809
|
function catalogEntryMatches(entry: TopoloServiceCatalogEntry, needle: string): boolean {
|
|
677
810
|
return catalogEntryLookupKeys(entry).includes(needle);
|
|
678
811
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createTopolo,
|
|
4
|
+
CrmModule,
|
|
5
|
+
IdentityModule,
|
|
6
|
+
TopoloClient,
|
|
7
|
+
TopoloAuthError,
|
|
8
|
+
TopoloHttpError,
|
|
9
|
+
TopoloPermissionError,
|
|
10
|
+
} from './index.js';
|
|
11
|
+
|
|
12
|
+
const AGENT = { clientName: 'topolo-sdk-test', clientVersion: '0.0.0' };
|
|
13
|
+
|
|
14
|
+
describe('createTopolo', () => {
|
|
15
|
+
it('returns a ready client plus identity + crm module surfaces', () => {
|
|
16
|
+
const topolo = createTopolo({
|
|
17
|
+
credential: { kind: 'access_token', accessToken: 'tok' },
|
|
18
|
+
agent: AGENT,
|
|
19
|
+
fetch: vi.fn() as unknown as typeof fetch,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(topolo.client).toBeInstanceOf(TopoloClient);
|
|
23
|
+
expect(topolo.identity).toBeInstanceOf(IdentityModule);
|
|
24
|
+
expect(topolo.crm).toBeInstanceOf(CrmModule);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('wires every module to the same underlying client', async () => {
|
|
28
|
+
const calls: string[] = [];
|
|
29
|
+
const impl = vi.fn(async (url: string) => {
|
|
30
|
+
calls.push(String(url));
|
|
31
|
+
if (String(url).includes('/api/services/by-slug/')) {
|
|
32
|
+
return new Response(JSON.stringify({ success: true, data: { service_id: 'service_auth' } }), {
|
|
33
|
+
status: 200,
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (String(url).includes('/api/services/catalog')) {
|
|
38
|
+
return new Response(JSON.stringify({ data: { services: [] } }), {
|
|
39
|
+
status: 200,
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return new Response(
|
|
44
|
+
JSON.stringify({ data: { user: { id: 'u_1', email: 'a@b.c' } } }),
|
|
45
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
46
|
+
);
|
|
47
|
+
}) as unknown as typeof fetch;
|
|
48
|
+
|
|
49
|
+
TopoloClient.clearServiceIdCache();
|
|
50
|
+
const topolo = createTopolo({
|
|
51
|
+
credential: { kind: 'access_token', accessToken: 'tok' },
|
|
52
|
+
agent: AGENT,
|
|
53
|
+
fetch: impl,
|
|
54
|
+
serviceIds: { auth: 'service_auth' },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const me = await topolo.identity.whoami();
|
|
58
|
+
expect(me.user.id).toBe('u_1');
|
|
59
|
+
expect(calls.some((u) => u.includes('/api/auth/me'))).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('package surface re-exports', () => {
|
|
64
|
+
it('re-exports the error classes from the barrel', () => {
|
|
65
|
+
expect(new TopoloAuthError('x').name).toBe('TopoloAuthError');
|
|
66
|
+
expect(new TopoloHttpError('auth', '/p', 500).name).toBe('TopoloHttpError');
|
|
67
|
+
expect(new TopoloPermissionError('nope', []).name).toBe('TopoloPermissionError');
|
|
68
|
+
});
|
|
69
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TopoloClient } from '../client.js';
|
|
3
|
+
import { CrmModule } from './crm.js';
|
|
4
|
+
|
|
5
|
+
const AGENT = { clientName: 'topolo-sdk-test', clientVersion: '0.0.0' };
|
|
6
|
+
|
|
7
|
+
function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
8
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
9
|
+
const impl = vi.fn(async (url: string, init: RequestInit) => {
|
|
10
|
+
calls.push({ url, init });
|
|
11
|
+
if (String(url).includes('/api/services/by-slug/')) {
|
|
12
|
+
return new Response(JSON.stringify({ success: true, data: { service_id: 'service_crm' } }), {
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
if (String(url).includes('/api/services/catalog')) {
|
|
18
|
+
return new Response(
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
data: {
|
|
21
|
+
services: [
|
|
22
|
+
{
|
|
23
|
+
service_id: 'srv_crm',
|
|
24
|
+
app_slug: 'topolo-crm',
|
|
25
|
+
name: 'Topolo CRM',
|
|
26
|
+
api_base_url: 'https://crm.topolo.app',
|
|
27
|
+
launch_url: 'https://crm.topolo.app',
|
|
28
|
+
surface_kind: 'api',
|
|
29
|
+
status: 'active',
|
|
30
|
+
granted_permissions: ['srv_crm.contacts:read'],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const next = responses.shift() ?? { status: 200, body: { data: {} } };
|
|
39
|
+
return new Response(JSON.stringify(next.body ?? {}), {
|
|
40
|
+
status: next.status ?? 200,
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
return { impl: impl as unknown as typeof fetch, calls };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
|
|
48
|
+
return calls.filter(
|
|
49
|
+
(call) =>
|
|
50
|
+
!call.url.includes('/api/services/by-slug/') &&
|
|
51
|
+
!call.url.includes('/api/services/catalog'),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clientWith(impl: typeof fetch) {
|
|
56
|
+
return new TopoloClient({
|
|
57
|
+
credential: { kind: 'access_token', accessToken: 'tok_test' },
|
|
58
|
+
agent: AGENT,
|
|
59
|
+
fetch: impl,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
TopoloClient.clearServiceIdCache();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('CrmModule.listContacts', () => {
|
|
68
|
+
it('maps snake_case rows into camelCase summaries and forwards query params', async () => {
|
|
69
|
+
const { impl, calls } = stubFetch([
|
|
70
|
+
{
|
|
71
|
+
body: {
|
|
72
|
+
contacts: [
|
|
73
|
+
{
|
|
74
|
+
id: 'c_1',
|
|
75
|
+
first: 'Ada',
|
|
76
|
+
last: 'Lovelace',
|
|
77
|
+
email: 'ada@example.com',
|
|
78
|
+
phone: '+1',
|
|
79
|
+
company: 'Analytical',
|
|
80
|
+
lead_status: 'new',
|
|
81
|
+
lifecycle_stage: 'lead',
|
|
82
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
total: 42,
|
|
86
|
+
page: 2,
|
|
87
|
+
pageSize: 25,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
const crm = new CrmModule(clientWith(impl));
|
|
92
|
+
|
|
93
|
+
const result = await crm.listContacts({ q: 'ada', page: 2, pageSize: 25 });
|
|
94
|
+
|
|
95
|
+
expect(result.contacts).toEqual([
|
|
96
|
+
{
|
|
97
|
+
id: 'c_1',
|
|
98
|
+
firstName: 'Ada',
|
|
99
|
+
lastName: 'Lovelace',
|
|
100
|
+
email: 'ada@example.com',
|
|
101
|
+
phone: '+1',
|
|
102
|
+
company: 'Analytical',
|
|
103
|
+
leadStatus: 'new',
|
|
104
|
+
lifecycleStage: 'lead',
|
|
105
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
106
|
+
},
|
|
107
|
+
]);
|
|
108
|
+
expect(result.total).toBe(42);
|
|
109
|
+
expect(result.page).toBe(2);
|
|
110
|
+
expect(result.pageSize).toBe(25);
|
|
111
|
+
|
|
112
|
+
const url = new URL(requestCalls(calls)[0]!.url);
|
|
113
|
+
expect(url.pathname).toBe('/api/contacts');
|
|
114
|
+
expect(url.searchParams.get('q')).toBe('ada');
|
|
115
|
+
expect(url.searchParams.get('page')).toBe('2');
|
|
116
|
+
expect(url.searchParams.get('pageSize')).toBe('25');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('reads rows from the data[] envelope and pagination block', async () => {
|
|
120
|
+
const { impl } = stubFetch([
|
|
121
|
+
{
|
|
122
|
+
body: {
|
|
123
|
+
data: [{ id: 'c_2' }],
|
|
124
|
+
pagination: { total: 7, page: 3, pageSize: 10 },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
const crm = new CrmModule(clientWith(impl));
|
|
129
|
+
|
|
130
|
+
const result = await crm.listContacts();
|
|
131
|
+
|
|
132
|
+
expect(result.contacts).toEqual([
|
|
133
|
+
{
|
|
134
|
+
id: 'c_2',
|
|
135
|
+
firstName: null,
|
|
136
|
+
lastName: null,
|
|
137
|
+
email: null,
|
|
138
|
+
phone: null,
|
|
139
|
+
company: null,
|
|
140
|
+
leadStatus: null,
|
|
141
|
+
lifecycleStage: null,
|
|
142
|
+
updatedAt: null,
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
expect(result.total).toBe(7);
|
|
146
|
+
expect(result.page).toBe(3);
|
|
147
|
+
expect(result.pageSize).toBe(10);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('falls back to row count + option defaults when no metadata is returned', async () => {
|
|
151
|
+
const { impl } = stubFetch([{ body: { contacts: [{ id: 'c_3' }, { id: 'c_4' }] } }]);
|
|
152
|
+
const crm = new CrmModule(clientWith(impl));
|
|
153
|
+
|
|
154
|
+
const result = await crm.listContacts();
|
|
155
|
+
|
|
156
|
+
expect(result.total).toBe(2);
|
|
157
|
+
expect(result.page).toBe(1);
|
|
158
|
+
expect(result.pageSize).toBe(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('returns an empty list when the response has neither contacts nor data arrays', async () => {
|
|
162
|
+
const { impl } = stubFetch([{ body: { unexpected: true } }]);
|
|
163
|
+
const crm = new CrmModule(clientWith(impl));
|
|
164
|
+
|
|
165
|
+
const result = await crm.listContacts();
|
|
166
|
+
|
|
167
|
+
expect(result.contacts).toEqual([]);
|
|
168
|
+
expect(result.total).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('CrmModule.getContact', () => {
|
|
173
|
+
it('rejects an empty contactId', async () => {
|
|
174
|
+
const { impl } = stubFetch([]);
|
|
175
|
+
const crm = new CrmModule(clientWith(impl));
|
|
176
|
+
await expect(crm.getContact('')).rejects.toThrow('contactId is required');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('unwraps a data-enveloped contact', async () => {
|
|
180
|
+
const { impl, calls } = stubFetch([
|
|
181
|
+
{ body: { data: { id: 'c_9', first: 'Grace', last: 'Hopper' } } },
|
|
182
|
+
]);
|
|
183
|
+
const crm = new CrmModule(clientWith(impl));
|
|
184
|
+
|
|
185
|
+
const contact = await crm.getContact('c_9');
|
|
186
|
+
|
|
187
|
+
expect(contact).toMatchObject({ id: 'c_9', firstName: 'Grace', lastName: 'Hopper' });
|
|
188
|
+
expect(new URL(requestCalls(calls)[0]!.url).pathname).toBe('/api/contacts/c_9');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('accepts a bare contact object (no data envelope)', async () => {
|
|
192
|
+
const { impl } = stubFetch([{ body: { id: 'c_10', email: 'g@example.com' } }]);
|
|
193
|
+
const crm = new CrmModule(clientWith(impl));
|
|
194
|
+
|
|
195
|
+
const contact = await crm.getContact('c_10');
|
|
196
|
+
|
|
197
|
+
expect(contact).toMatchObject({ id: 'c_10', email: 'g@example.com' });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('url-encodes contact ids with special characters', async () => {
|
|
201
|
+
const { impl, calls } = stubFetch([{ body: { data: { id: 'a/b c' } } }]);
|
|
202
|
+
const crm = new CrmModule(clientWith(impl));
|
|
203
|
+
|
|
204
|
+
await crm.getContact('a/b c');
|
|
205
|
+
|
|
206
|
+
expect(requestCalls(calls)[0]!.url).toContain('/api/contacts/a%2Fb%20c');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TopoloClient } from '../client.js';
|
|
3
|
+
import { IdentityModule } from './identity.js';
|
|
4
|
+
|
|
5
|
+
const AGENT = { clientName: 'topolo-sdk-test', clientVersion: '0.0.0' };
|
|
6
|
+
|
|
7
|
+
function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
8
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
9
|
+
const impl = vi.fn(async (url: string, init: RequestInit) => {
|
|
10
|
+
calls.push({ url, init });
|
|
11
|
+
if (String(url).includes('/api/services/by-slug/')) {
|
|
12
|
+
return new Response(JSON.stringify({ success: true, data: { service_id: 'service_auth' } }), {
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
if (String(url).includes('/api/services/catalog')) {
|
|
18
|
+
return new Response(JSON.stringify({ data: { services: [] } }), {
|
|
19
|
+
status: 200,
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const next = responses.shift() ?? { status: 200, body: {} };
|
|
24
|
+
return new Response(JSON.stringify(next.body ?? {}), {
|
|
25
|
+
status: next.status ?? 200,
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
return { impl: impl as unknown as typeof fetch, calls };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
|
|
33
|
+
return calls.filter(
|
|
34
|
+
(call) =>
|
|
35
|
+
!call.url.includes('/api/services/by-slug/') &&
|
|
36
|
+
!call.url.includes('/api/services/catalog'),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clientWith(
|
|
41
|
+
impl: typeof fetch,
|
|
42
|
+
credential: { kind: 'access_token'; accessToken: string } | { kind: 'api_key'; apiKey: string } = {
|
|
43
|
+
kind: 'access_token',
|
|
44
|
+
accessToken: 'tok_test',
|
|
45
|
+
},
|
|
46
|
+
) {
|
|
47
|
+
return new TopoloClient({
|
|
48
|
+
credential,
|
|
49
|
+
agent: AGENT,
|
|
50
|
+
fetch: impl,
|
|
51
|
+
serviceIds: { auth: 'service_auth' },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
TopoloClient.clearServiceIdCache();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('IdentityModule.whoami', () => {
|
|
60
|
+
it('resolves a data-enveloped access-token identity with an organization', async () => {
|
|
61
|
+
const { impl, calls } = stubFetch([
|
|
62
|
+
{
|
|
63
|
+
body: {
|
|
64
|
+
data: {
|
|
65
|
+
credentialType: 'access_token',
|
|
66
|
+
user: { id: 'u_1', email: 'ada@example.com', name: 'Ada', role: 'owner' },
|
|
67
|
+
organization: { id: 'org_1', slug: 'acme', name: 'Acme Inc' },
|
|
68
|
+
permissions: ['crm.contacts:read'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
const identity = new IdentityModule(clientWith(impl));
|
|
74
|
+
|
|
75
|
+
const me = await identity.whoami();
|
|
76
|
+
|
|
77
|
+
expect(me).toEqual({
|
|
78
|
+
kind: 'access_token',
|
|
79
|
+
user: {
|
|
80
|
+
id: 'u_1',
|
|
81
|
+
email: 'ada@example.com',
|
|
82
|
+
name: 'Ada',
|
|
83
|
+
role: 'owner',
|
|
84
|
+
permissions: ['crm.contacts:read'],
|
|
85
|
+
},
|
|
86
|
+
organization: { id: 'org_1', slug: 'acme', name: 'Acme Inc' },
|
|
87
|
+
});
|
|
88
|
+
expect(new URL(requestCalls(calls)[0]!.url).pathname).toBe('/api/auth/me');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('defaults api_key role to "service" and reads a bare (non-enveloped) payload', async () => {
|
|
92
|
+
const { impl } = stubFetch([
|
|
93
|
+
{
|
|
94
|
+
body: {
|
|
95
|
+
credentialType: 'api_key',
|
|
96
|
+
user: { id: 'svc_1', email: 'svc@example.com' },
|
|
97
|
+
organization: { id: 'org_2', slug: 'beta' },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
const identity = new IdentityModule(
|
|
102
|
+
clientWith(impl, { kind: 'api_key', apiKey: 'dak_xyz' }),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const me = await identity.whoami();
|
|
106
|
+
|
|
107
|
+
expect(me.kind).toBe('api_key');
|
|
108
|
+
expect(me.user.role).toBe('service');
|
|
109
|
+
expect(me.user.name).toBeNull();
|
|
110
|
+
// organization name falls back to slug when name is absent
|
|
111
|
+
expect(me.organization).toEqual({ id: 'org_2', slug: 'beta', name: 'beta' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('defaults access_token role to "member" and reads permissions from user when top-level absent', async () => {
|
|
115
|
+
const { impl } = stubFetch([
|
|
116
|
+
{
|
|
117
|
+
body: {
|
|
118
|
+
data: {
|
|
119
|
+
user: {
|
|
120
|
+
id: 'u_3',
|
|
121
|
+
email: 'm@example.com',
|
|
122
|
+
permissions: ['mail.messages:read'],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
const identity = new IdentityModule(clientWith(impl));
|
|
129
|
+
|
|
130
|
+
const me = await identity.whoami();
|
|
131
|
+
|
|
132
|
+
expect(me.kind).toBe('access_token');
|
|
133
|
+
expect(me.user.role).toBe('member');
|
|
134
|
+
expect(me.user.permissions).toEqual(['mail.messages:read']);
|
|
135
|
+
expect(me.organization).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('synthesizes an organization from user.orgId/orgSlug when no organization object is present', async () => {
|
|
139
|
+
const { impl } = stubFetch([
|
|
140
|
+
{
|
|
141
|
+
body: {
|
|
142
|
+
data: {
|
|
143
|
+
user: { id: 'u_4', email: 'o@example.com', orgId: 'org_9', orgSlug: 'gamma' },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
]);
|
|
148
|
+
const identity = new IdentityModule(clientWith(impl));
|
|
149
|
+
|
|
150
|
+
const me = await identity.whoami();
|
|
151
|
+
|
|
152
|
+
expect(me.organization).toEqual({ id: 'org_9', slug: 'gamma', name: 'gamma' });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('defaults missing permissions to an empty array', async () => {
|
|
156
|
+
const { impl } = stubFetch([
|
|
157
|
+
{ body: { data: { user: { id: 'u_5', email: 'n@example.com' } } } },
|
|
158
|
+
]);
|
|
159
|
+
const identity = new IdentityModule(clientWith(impl));
|
|
160
|
+
|
|
161
|
+
const me = await identity.whoami();
|
|
162
|
+
|
|
163
|
+
expect(me.user.permissions).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
});
|