@topolo/sdk 0.4.6 → 0.4.8

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.
@@ -4,7 +4,7 @@ import {
4
4
  type ApplicationId,
5
5
  } from './applications.generated.js';
6
6
 
7
- export const APPLICATION_REQUIREMENTS_VERSION = '2026-05-01.1';
7
+ export const APPLICATION_REQUIREMENTS_VERSION = '2026-06-07.1';
8
8
 
9
9
  export type ApplicationRequirementScope =
10
10
  | 'all'
@@ -125,16 +125,20 @@ export const APPLICATION_REQUIREMENTS = [
125
125
  'Browser applications should feel like one platform and should not create isolated navigation or account surfaces.',
126
126
  appliesTo: ['browser'],
127
127
  evidence: [
128
- '@topolo/ui-kit is used for the platform shell where the app has authenticated UI',
129
- 'The shared launcher/app switcher is reachable from authenticated app chrome',
130
- 'Account menu and sign-out behavior use TopoloAppShell or TopoloAccountMenu',
128
+ '@topolo-io/app-shell is used for the platform shell where the app has authenticated UI',
129
+ '@topolo-io/app-launcher keeps the shared launcher/app switcher reachable from authenticated app chrome',
130
+ 'Account menu and sign-out behavior use TopoloAppShell or the ui-kit TopoloAccountMenu primitive',
131
+ 'Repeated app body surfaces use shared TopoloAppLayout primitives instead of local stat cards, empty states, or status badges',
132
+ 'Local preview supports deterministic visual QA fixtures for overview, list, detail, settings, and empty/loading states',
131
133
  'Authenticated app body content stays inside the viewport on mobile and desktop widths',
132
134
  ],
133
135
  implementation: [
134
- 'Use TopoloAppShell for authenticated app layouts',
135
- 'Use TopoloAccountMenu when a partial-shell surface still needs account chrome',
136
+ 'Use @topolo-io/app-shell TopoloAppShell for authenticated app layouts',
137
+ 'Use the @topolo-io/ui-kit TopoloAccountMenu primitive when a partial-shell surface still needs account chrome',
136
138
  'Pass app-specific account actions as additive menu items instead of forking dropdowns',
137
139
  'Use shared design tokens and platform UI primitives before creating local equivalents',
140
+ 'Use TopoloAppMetricCard, TopoloAppEmptyState, TopoloAppStatusBadge, TopoloAppGrid, and TopoloAppSection for repeated app body patterns',
141
+ 'Keep visual QA fixtures available through visualQa=1 or __devpreview=1 so screenshot checks do not depend on live API state',
138
142
  'Keep mobile and desktop shell behavior aligned with the platform UI kit',
139
143
  'Constrain app-owned workspace wrappers with min-width: 0, width: auto, and viewport-aware max-width so shell padding cannot create page-level horizontal overflow',
140
144
  ],
@@ -218,11 +222,13 @@ export const APPLICATION_REQUIREMENTS = [
218
222
  evidence: [
219
223
  'The app-owned API exposes GET /api/widget',
220
224
  'The endpoint returns TopoloWidgetApiResponse from @topolo/sdk',
225
+ 'Every returned widget includes snapshot.summary with app-owned operational prose',
221
226
  'The endpoint derives organization/user context from the app auth boundary',
222
227
  ],
223
228
  implementation: [
224
229
  'Import createTopoloWidgetResponse from @topolo/sdk in the app backend',
225
230
  'Return native app metrics or action widgets instead of generic launch-card fallbacks',
231
+ 'Populate snapshot.summary in the producing app for every widget instead of synthesizing summaries in TopoloOne',
226
232
  'Add a route-level test that validates the response with validateTopoloWidgetResponse',
227
233
  ],
228
234
  },
@@ -49,17 +49,15 @@ export const APPLICATIONS = {
49
49
  "agent.topolo.app"
50
50
  ],
51
51
  "routes": [],
52
- "serviceId": null
52
+ "serviceId": "agent"
53
53
  },
54
54
  {
55
55
  "name": "topolo-agent-api",
56
56
  "kind": "worker",
57
- "apiUrl": "https://agent-api.topolo.app",
58
- "domains": [
59
- "agent-api.topolo.app"
60
- ],
57
+ "apiUrl": null,
58
+ "domains": [],
61
59
  "routes": [],
62
- "serviceId": "agent"
60
+ "serviceId": null
63
61
  }
64
62
  ]
65
63
  },
@@ -179,17 +177,15 @@ export const APPLICATIONS = {
179
177
  "bytes.topolo.app"
180
178
  ],
181
179
  "routes": [],
182
- "serviceId": null
180
+ "serviceId": "bytes"
183
181
  },
184
182
  {
185
183
  "name": "topolo-bytes-api",
186
184
  "kind": "worker",
187
- "apiUrl": "https://bytes-api.topolo.app",
188
- "domains": [
189
- "bytes-api.topolo.app"
190
- ],
185
+ "apiUrl": null,
186
+ "domains": [],
191
187
  "routes": [],
192
- "serviceId": "bytes"
188
+ "serviceId": null
193
189
  }
194
190
  ]
195
191
  },
@@ -288,12 +284,10 @@ export const APPLICATIONS = {
288
284
  {
289
285
  "name": "topolo-commerce-api",
290
286
  "kind": "worker",
291
- "apiUrl": "https://commerce-api.topolo.app",
292
- "domains": [
293
- "commerce-api.topolo.app"
294
- ],
287
+ "apiUrl": null,
288
+ "domains": [],
295
289
  "routes": [],
296
- "serviceId": "commerce"
290
+ "serviceId": null
297
291
  },
298
292
  {
299
293
  "name": "topolo-commerce-guest-web",
@@ -313,7 +307,7 @@ export const APPLICATIONS = {
313
307
  "commerce.topolo.app"
314
308
  ],
315
309
  "routes": [],
316
- "serviceId": null
310
+ "serviceId": "commerce"
317
311
  },
318
312
  {
319
313
  "name": "topolo-commerce-station-web",
@@ -400,12 +394,10 @@ export const APPLICATIONS = {
400
394
  {
401
395
  "name": "topolo-crm-api",
402
396
  "kind": "worker",
403
- "apiUrl": "https://crm-api.topolo.app",
404
- "domains": [
405
- "crm-api.topolo.app"
406
- ],
397
+ "apiUrl": null,
398
+ "domains": [],
407
399
  "routes": [],
408
- "serviceId": "crm"
400
+ "serviceId": null
409
401
  },
410
402
  {
411
403
  "name": "topolo-crm-app",
@@ -415,7 +407,7 @@ export const APPLICATIONS = {
415
407
  "crm.topolo.app"
416
408
  ],
417
409
  "routes": [],
418
- "serviceId": null
410
+ "serviceId": "crm"
419
411
  }
420
412
  ]
421
413
  },
@@ -538,12 +530,10 @@ export const APPLICATIONS = {
538
530
  {
539
531
  "name": "topolo-feed-api",
540
532
  "kind": "worker",
541
- "apiUrl": "https://feed-api.topolo.app",
542
- "domains": [
543
- "feed-api.topolo.app"
544
- ],
533
+ "apiUrl": null,
534
+ "domains": [],
545
535
  "routes": [],
546
- "serviceId": "feed"
536
+ "serviceId": null
547
537
  },
548
538
  {
549
539
  "name": "topolo-feed-web",
@@ -553,7 +543,7 @@ export const APPLICATIONS = {
553
543
  "feed.topolo.app"
554
544
  ],
555
545
  "routes": [],
556
- "serviceId": null
546
+ "serviceId": "feed"
557
547
  }
558
548
  ]
559
549
  },
@@ -605,17 +595,15 @@ export const APPLICATIONS = {
605
595
  "forecast.topolo.app"
606
596
  ],
607
597
  "routes": [],
608
- "serviceId": null
598
+ "serviceId": "forecast"
609
599
  },
610
600
  {
611
601
  "name": "topolo-forecast-api",
612
602
  "kind": "worker",
613
- "apiUrl": "https://forecast-api.topolo.app",
614
- "domains": [
615
- "forecast-api.topolo.app"
616
- ],
603
+ "apiUrl": null,
604
+ "domains": [],
617
605
  "routes": [],
618
- "serviceId": "forecast"
606
+ "serviceId": null
619
607
  }
620
608
  ]
621
609
  },
@@ -757,17 +745,15 @@ export const APPLICATIONS = {
757
745
  "learn.topolo.app"
758
746
  ],
759
747
  "routes": [],
760
- "serviceId": null
748
+ "serviceId": "learn"
761
749
  },
762
750
  {
763
751
  "name": "topolo-learn-api",
764
752
  "kind": "worker",
765
- "apiUrl": "https://learn-api.topolo.app",
766
- "domains": [
767
- "learn-api.topolo.app"
768
- ],
753
+ "apiUrl": null,
754
+ "domains": [],
769
755
  "routes": [],
770
- "serviceId": "learn"
756
+ "serviceId": null
771
757
  }
772
758
  ]
773
759
  },
@@ -813,17 +799,15 @@ export const APPLICATIONS = {
813
799
  "mail.topolo.app"
814
800
  ],
815
801
  "routes": [],
816
- "serviceId": null
802
+ "serviceId": "mail"
817
803
  },
818
804
  {
819
805
  "name": "topolo-mail-api",
820
806
  "kind": "worker",
821
- "apiUrl": "https://mail-api.topolo.app",
822
- "domains": [
823
- "mail-api.topolo.app"
824
- ],
807
+ "apiUrl": null,
808
+ "domains": [],
825
809
  "routes": [],
826
- "serviceId": "mail"
810
+ "serviceId": null
827
811
  }
828
812
  ]
829
813
  },
@@ -840,12 +824,10 @@ export const APPLICATIONS = {
840
824
  {
841
825
  "name": "topolo-mdm-api",
842
826
  "kind": "worker",
843
- "apiUrl": "https://mdm-api.topolo.app",
844
- "domains": [
845
- "mdm-api.topolo.app"
846
- ],
827
+ "apiUrl": null,
828
+ "domains": [],
847
829
  "routes": [],
848
- "serviceId": "mdm"
830
+ "serviceId": null
849
831
  },
850
832
  {
851
833
  "name": "topolo-mdm-console",
@@ -855,7 +837,7 @@ export const APPLICATIONS = {
855
837
  "mdm.topolo.app"
856
838
  ],
857
839
  "routes": [],
858
- "serviceId": null
840
+ "serviceId": "mdm"
859
841
  }
860
842
  ]
861
843
  },
@@ -988,20 +970,23 @@ export const APPLICATIONS = {
988
970
  {
989
971
  "name": "topolo-one",
990
972
  "kind": "worker",
991
- "apiUrl": null,
992
- "domains": [],
993
- "routes": [],
973
+ "apiUrl": "https://one.topolo.app",
974
+ "domains": [
975
+ "one.topolo.app"
976
+ ],
977
+ "routes": [
978
+ "one.topolo.app/*"
979
+ ],
994
980
  "serviceId": null
995
981
  },
996
982
  {
997
983
  "name": "topolo-one-api",
998
984
  "kind": "worker",
999
- "apiUrl": "https://one.topolo.app",
985
+ "apiUrl": "https://www.topolo.app",
1000
986
  "domains": [
1001
- "one.topolo.app"
987
+ "www.topolo.app"
1002
988
  ],
1003
989
  "routes": [
1004
- "one.topolo.app/*",
1005
990
  "www.topolo.app/*"
1006
991
  ],
1007
992
  "serviceId": "one"
@@ -1111,36 +1096,6 @@ export const APPLICATIONS = {
1111
1096
  }
1112
1097
  ]
1113
1098
  },
1114
- "pulse": {
1115
- "id": "pulse",
1116
- "appDir": "TopoloPulse",
1117
- "name": "topolo-pulse",
1118
- "packageName": "topolo-pulse",
1119
- "productionUrl": "https://pulse.topolo.app",
1120
- "services": [
1121
- "pulse"
1122
- ],
1123
- "deployTargets": [
1124
- {
1125
- "name": "topolo-pulse",
1126
- "kind": "worker",
1127
- "apiUrl": "https://pulse.topolo.app",
1128
- "domains": [
1129
- "pulse.topolo.app"
1130
- ],
1131
- "routes": [],
1132
- "serviceId": "pulse"
1133
- },
1134
- {
1135
- "name": "topolo-pulse-api",
1136
- "kind": "worker",
1137
- "apiUrl": null,
1138
- "domains": [],
1139
- "routes": [],
1140
- "serviceId": null
1141
- }
1142
- ]
1143
- },
1144
1099
  "quro": {
1145
1100
  "id": "quro",
1146
1101
  "appDir": "TopoloQuro",
@@ -1206,17 +1161,15 @@ export const APPLICATIONS = {
1206
1161
  "roadmapper.topolo.app"
1207
1162
  ],
1208
1163
  "routes": [],
1209
- "serviceId": null
1164
+ "serviceId": "roadmapper"
1210
1165
  },
1211
1166
  {
1212
1167
  "name": "topolo-roadmapper-api",
1213
1168
  "kind": "worker",
1214
- "apiUrl": "https://roadmapper-api.topolo.app",
1215
- "domains": [
1216
- "roadmapper-api.topolo.app"
1217
- ],
1169
+ "apiUrl": null,
1170
+ "domains": [],
1218
1171
  "routes": [],
1219
- "serviceId": "roadmapper"
1172
+ "serviceId": null
1220
1173
  }
1221
1174
  ]
1222
1175
  },
@@ -1268,17 +1221,15 @@ export const APPLICATIONS = {
1268
1221
  "studio.topolo.app"
1269
1222
  ],
1270
1223
  "routes": [],
1271
- "serviceId": null
1224
+ "serviceId": "social_studio"
1272
1225
  },
1273
1226
  {
1274
1227
  "name": "topolo-social-studio-api",
1275
1228
  "kind": "worker",
1276
- "apiUrl": "https://studio-api.topolo.app",
1277
- "domains": [
1278
- "studio-api.topolo.app"
1279
- ],
1229
+ "apiUrl": null,
1230
+ "domains": [],
1280
1231
  "routes": [],
1281
- "serviceId": "social_studio"
1232
+ "serviceId": null
1282
1233
  },
1283
1234
  {
1284
1235
  "name": "topolo-social-studio-queue",
@@ -1308,17 +1259,15 @@ export const APPLICATIONS = {
1308
1259
  "socialize.topolo.app"
1309
1260
  ],
1310
1261
  "routes": [],
1311
- "serviceId": null
1262
+ "serviceId": "socialize"
1312
1263
  },
1313
1264
  {
1314
1265
  "name": "topolo-socialize-api",
1315
1266
  "kind": "worker",
1316
- "apiUrl": "https://socialize-api.topolo.app",
1317
- "domains": [
1318
- "socialize-api.topolo.app"
1319
- ],
1267
+ "apiUrl": null,
1268
+ "domains": [],
1320
1269
  "routes": [],
1321
- "serviceId": "socialize"
1270
+ "serviceId": null
1322
1271
  },
1323
1272
  {
1324
1273
  "name": "topolo-socialize-scheduler",
@@ -1519,12 +1468,10 @@ export const APPLICATIONS = {
1519
1468
  {
1520
1469
  "name": "topolo-web-api",
1521
1470
  "kind": "worker",
1522
- "apiUrl": "https://web-api.topolo.app",
1523
- "domains": [
1524
- "web-api.topolo.app"
1525
- ],
1471
+ "apiUrl": null,
1472
+ "domains": [],
1526
1473
  "routes": [],
1527
- "serviceId": "web"
1474
+ "serviceId": null
1528
1475
  },
1529
1476
  {
1530
1477
  "name": "topolo-web-runtime",
@@ -1547,7 +1494,7 @@ export const APPLICATIONS = {
1547
1494
  "web.topolo.app"
1548
1495
  ],
1549
1496
  "routes": [],
1550
- "serviceId": null
1497
+ "serviceId": "web"
1551
1498
  }
1552
1499
  ]
1553
1500
  }
@@ -143,6 +143,40 @@ describe('TopoloClient.request', () => {
143
143
  expect(headers.get('Authorization')).toBeNull();
144
144
  });
145
145
 
146
+ it('adds canonical resource headers for resource-scoped API keys', async () => {
147
+ const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { ok: true } }]);
148
+ const client = new TopoloClient({
149
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
150
+ agent: AGENT,
151
+ fetch: impl,
152
+ });
153
+ await client.request({
154
+ service: 'mail',
155
+ path: '/api/mail/mailboxes/mbx_1/messages',
156
+ resource: { resourceType: 'mailbox', resourceId: 'mbx_1' },
157
+ });
158
+ const headers = new Headers(requestCalls(calls)[0]!.init.headers);
159
+ expect(headers.get('X-Topolo-Resource-Type')).toBe('mailbox');
160
+ expect(headers.get('X-Topolo-Resource-ID')).toBe('mbx_1');
161
+ });
162
+
163
+ it('passes arbitrary cataloged workspace aliases through resource headers', async () => {
164
+ const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { ok: true } }]);
165
+ const client = new TopoloClient({
166
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
167
+ agent: AGENT,
168
+ fetch: impl,
169
+ });
170
+ await client.request({
171
+ service: 'mail',
172
+ path: '/api/example',
173
+ resource: { resourceType: 'studio_portfolio', resourceId: 'portfolio_1' },
174
+ });
175
+ const headers = new Headers(requestCalls(calls)[0]!.init.headers);
176
+ expect(headers.get('X-Topolo-Resource-Type')).toBe('studio_portfolio');
177
+ expect(headers.get('X-Topolo-Resource-ID')).toBe('portfolio_1');
178
+ });
179
+
146
180
  it('refuses mutating requests without confirm', async () => {
147
181
  const { impl } = stubFetch([]);
148
182
  const client = new TopoloClient({
@@ -458,6 +492,26 @@ describe('TopoloClient.listServices', () => {
458
492
  status: 'active',
459
493
  surface_kind: 'application',
460
494
  launchable: true,
495
+ api_key_resource: {
496
+ resourceType: 'brand',
497
+ label: 'Brands',
498
+ emptyLabel: 'All brands in the organization',
499
+ resourceConcept: 'workspace',
500
+ canonicalResourceType: 'workspace',
501
+ defaultEntitlement: 'enterprise',
502
+ exceptionPolicy: 'app_or_organization',
503
+ },
504
+ api_key_resources: [
505
+ {
506
+ resourceType: 'brand',
507
+ label: 'Brands',
508
+ emptyLabel: 'All brands in the organization',
509
+ resourceConcept: 'workspace',
510
+ canonicalResourceType: 'workspace',
511
+ defaultEntitlement: 'enterprise',
512
+ exceptionPolicy: 'app_or_organization',
513
+ },
514
+ ],
461
515
  granted_permissions: ['srv_wRCnLLbtvmVT.dashboard:read'],
462
516
  },
463
517
  ],
@@ -483,6 +537,26 @@ describe('TopoloClient.listServices', () => {
483
537
  status: 'active',
484
538
  surfaceKind: 'application',
485
539
  launchable: true,
540
+ apiKeyResource: {
541
+ resourceType: 'brand',
542
+ label: 'Brands',
543
+ emptyLabel: 'All brands in the organization',
544
+ resourceConcept: 'workspace',
545
+ canonicalResourceType: 'workspace',
546
+ defaultEntitlement: 'enterprise',
547
+ exceptionPolicy: 'app_or_organization',
548
+ },
549
+ apiKeyResources: [
550
+ {
551
+ resourceType: 'brand',
552
+ label: 'Brands',
553
+ emptyLabel: 'All brands in the organization',
554
+ resourceConcept: 'workspace',
555
+ canonicalResourceType: 'workspace',
556
+ defaultEntitlement: 'enterprise',
557
+ exceptionPolicy: 'app_or_organization',
558
+ },
559
+ ],
486
560
  permissions: ['srv_wRCnLLbtvmVT.dashboard:read'],
487
561
  },
488
562
  ]);
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);
@@ -447,6 +478,18 @@ export class TopoloClient {
447
478
  ? s.granted_permissions
448
479
  .filter((permission): permission is string => typeof permission === 'string' && permission.trim().length > 0)
449
480
  : [];
481
+ const catalogApiKeyResources = normalizeApiKeyResourceMetadataList(
482
+ s.api_key_resources ?? s.apiKeyResources,
483
+ );
484
+ const apiKeyResource =
485
+ normalizeApiKeyResourceMetadata(s.api_key_resource ?? s.apiKeyResource) ??
486
+ catalogApiKeyResources[0] ??
487
+ null;
488
+ const apiKeyResources = catalogApiKeyResources.length > 0
489
+ ? catalogApiKeyResources
490
+ : apiKeyResource
491
+ ? [apiKeyResource]
492
+ : [];
450
493
  entries.push({
451
494
  serviceId,
452
495
  slug: typeof s.app_slug === 'string' ? s.app_slug : null,
@@ -470,6 +513,8 @@ export class TopoloClient {
470
513
  typeof s.surface_kind === 'string' ? s.surface_kind : null,
471
514
  launchable: s.launchable === true,
472
515
  permissions,
516
+ apiKeyResource,
517
+ apiKeyResources,
473
518
  });
474
519
  }
475
520
  this.serviceCatalogCache = {
@@ -577,6 +622,8 @@ export class TopoloClient {
577
622
  surfaceKind: 'application',
578
623
  launchable: Boolean(app.productionUrl),
579
624
  permissions: [],
625
+ apiKeyResource: null,
626
+ apiKeyResources: [],
580
627
  }));
581
628
  }
582
629
 
@@ -640,6 +687,60 @@ function resolveConfiguredServiceId(
640
687
  return null;
641
688
  }
642
689
 
690
+ function normalizeApiKeyResourceMetadata(input: unknown): TopoloApiKeyResourceMetadata | null {
691
+ if (!isRecord(input)) return null;
692
+ const resourceType = readString(input.resourceType ?? input.resource_type);
693
+ const label = readString(input.label);
694
+ const emptyLabel = readString(input.emptyLabel ?? input.empty_label);
695
+ const resourceConcept = readString(input.resourceConcept ?? input.resource_concept);
696
+ const canonicalResourceType = readString(input.canonicalResourceType ?? input.canonical_resource_type);
697
+ const defaultEntitlement = readString(input.defaultEntitlement ?? input.default_entitlement);
698
+ const exceptionPolicy = readString(input.exceptionPolicy ?? input.exception_policy);
699
+
700
+ if (
701
+ !resourceType ||
702
+ !label ||
703
+ !emptyLabel ||
704
+ resourceConcept !== 'workspace' ||
705
+ canonicalResourceType !== 'workspace' ||
706
+ defaultEntitlement !== 'enterprise' ||
707
+ exceptionPolicy !== 'app_or_organization'
708
+ ) {
709
+ return null;
710
+ }
711
+
712
+ return {
713
+ resourceType,
714
+ label,
715
+ emptyLabel,
716
+ resourceConcept,
717
+ canonicalResourceType,
718
+ defaultEntitlement,
719
+ exceptionPolicy,
720
+ };
721
+ }
722
+
723
+ function normalizeApiKeyResourceMetadataList(input: unknown): TopoloApiKeyResourceMetadata[] {
724
+ if (!Array.isArray(input)) return [];
725
+ const resources: TopoloApiKeyResourceMetadata[] = [];
726
+ const seenTypes = new Set<string>();
727
+ for (const item of input) {
728
+ const resource = normalizeApiKeyResourceMetadata(item);
729
+ if (!resource || seenTypes.has(resource.resourceType)) continue;
730
+ seenTypes.add(resource.resourceType);
731
+ resources.push(resource);
732
+ }
733
+ return resources;
734
+ }
735
+
736
+ function readString(value: unknown): string {
737
+ return typeof value === 'string' ? value.trim() : '';
738
+ }
739
+
740
+ function isRecord(value: unknown): value is Record<string, unknown> {
741
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
742
+ }
743
+
643
744
  function resolveConfiguredServiceUrl(
644
745
  slug: string,
645
746
  serviceUrls?: Record<string, string>,