@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/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
- if (serviceKey === 'auth') {
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
- return { serviceKey, baseUrl: DEFAULT_SERVICE_URLS.auth };
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
- const authBase = resolveServiceUrl('auth', this.serviceUrls);
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: 'auth', method: 'GET', path: '/api/services/catalog' });
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
@@ -5,6 +5,7 @@ export {
5
5
  type CredentialIntrospection,
6
6
  type TopoloDebugEvent,
7
7
  type TopoloServiceCatalogEntry,
8
+ type TopoloApiKeyResourceMetadata,
8
9
  type TopoloActionCatalogEntry,
9
10
  } from "./client.js";
10
11
  export type { TopoloCredential, AgentIdentity } from "./auth.js";
@@ -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
+ });