@topolo/sdk 0.9.6 → 0.9.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.
package/dist/index.cjs CHANGED
@@ -51,6 +51,9 @@ __export(index_exports, {
51
51
  defineTopoloStatsWidget: () => defineTopoloStatsWidget,
52
52
  digestTopoloActionCatalog: () => digestTopoloActionCatalog,
53
53
  enrichActionContract: () => enrichActionContract,
54
+ parseTopoloActionUploadContract: () => parseTopoloActionUploadContract,
55
+ prepareTopoloActionBase64UploadInput: () => prepareTopoloActionBase64UploadInput,
56
+ prepareTopoloActionUploadInput: () => prepareTopoloActionUploadInput,
54
57
  requirementsForApplication: () => requirementsForApplication,
55
58
  resolveCatalogServiceUrl: () => resolveCatalogServiceUrl,
56
59
  resolveServiceUrl: () => resolveServiceUrl,
@@ -89,19 +92,54 @@ var TopoloSdkError = class extends Error {
89
92
  }
90
93
  };
91
94
  var TopoloAuthError = class extends TopoloSdkError {
92
- constructor(message, code = "auth_error") {
95
+ status;
96
+ body;
97
+ service;
98
+ path;
99
+ requestId;
100
+ retryAfter;
101
+ constructor(message, code = "auth_error", options = {}) {
93
102
  super(code, message);
94
103
  this.name = "TopoloAuthError";
104
+ this.status = options.status ?? 401;
105
+ this.body = options.body;
106
+ this.service = options.service;
107
+ this.path = options.path;
108
+ this.requestId = options.requestId;
109
+ this.retryAfter = options.retryAfter;
95
110
  }
96
111
  };
97
112
  var TopoloPermissionError = class extends TopoloSdkError {
98
113
  required;
99
- constructor(message, required) {
114
+ status;
115
+ body;
116
+ service;
117
+ path;
118
+ requestId;
119
+ retryAfter;
120
+ constructor(message, required, options = {}) {
100
121
  super("permission_denied", message);
101
122
  this.name = "TopoloPermissionError";
102
123
  this.required = required;
124
+ this.status = options.status ?? 403;
125
+ this.body = options.body;
126
+ this.service = options.service;
127
+ this.path = options.path;
128
+ this.requestId = options.requestId;
129
+ this.retryAfter = options.retryAfter;
103
130
  }
104
131
  };
132
+ function extractHttpErrorCode(body) {
133
+ if (!body || typeof body !== "object" || Array.isArray(body)) return "http_error";
134
+ const record = body;
135
+ if (typeof record.code === "string" && record.code.trim()) return record.code.trim();
136
+ const nested = record.error;
137
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
138
+ const code = nested.code;
139
+ if (typeof code === "string" && code.trim()) return code.trim();
140
+ }
141
+ return "http_error";
142
+ }
105
143
  var TopoloConfirmationRequiredError = class extends TopoloSdkError {
106
144
  constructor(message) {
107
145
  super("confirmation_required", message);
@@ -113,13 +151,17 @@ var TopoloHttpError = class extends TopoloSdkError {
113
151
  body;
114
152
  service;
115
153
  path;
116
- constructor(service, path, status, body, message) {
117
- super("http_error", message ?? `HTTP ${status} from ${service}${path}`);
154
+ requestId;
155
+ retryAfter;
156
+ constructor(service, path, status, body, message, options = {}) {
157
+ super(extractHttpErrorCode(body), message ?? `HTTP ${status} from ${service}${path}`);
118
158
  this.name = "TopoloHttpError";
119
159
  this.status = status;
120
160
  this.body = body;
121
161
  this.service = service;
122
162
  this.path = path;
163
+ this.requestId = options.requestId;
164
+ this.retryAfter = options.retryAfter;
123
165
  }
124
166
  };
125
167
 
@@ -333,7 +375,9 @@ var APPLICATIONS = {
333
375
  "domains": [
334
376
  "brand.topolo.app"
335
377
  ],
336
- "routes": [],
378
+ "routes": [
379
+ "brand.topolo.app"
380
+ ],
337
381
  "appId": "brand"
338
382
  },
339
383
  {
@@ -505,7 +549,9 @@ var APPLICATIONS = {
505
549
  "domains": [
506
550
  "chat.topolo.app"
507
551
  ],
508
- "routes": [],
552
+ "routes": [
553
+ "chat.topolo.app"
554
+ ],
509
555
  "appId": "chat"
510
556
  },
511
557
  {
@@ -1065,7 +1111,9 @@ var APPLICATIONS = {
1065
1111
  "domains": [
1066
1112
  "mail.topolo.app"
1067
1113
  ],
1068
- "routes": [],
1114
+ "routes": [
1115
+ "mail.topolo.app"
1116
+ ],
1069
1117
  "appId": "mail"
1070
1118
  },
1071
1119
  {
@@ -1416,7 +1464,9 @@ var APPLICATIONS = {
1416
1464
  "domains": [
1417
1465
  "quro.topolo.app"
1418
1466
  ],
1419
- "routes": [],
1467
+ "routes": [
1468
+ "quro.topolo.app"
1469
+ ],
1420
1470
  "appId": null
1421
1471
  }
1422
1472
  ]
@@ -2498,6 +2548,168 @@ function isMultipleOf(value, divisor) {
2498
2548
  return Math.abs(quotient - Math.round(quotient)) < Number.EPSILON * Math.max(1, Math.abs(quotient)) * 4;
2499
2549
  }
2500
2550
 
2551
+ // src/action-upload.ts
2552
+ function parseTopoloActionUploadContract(value) {
2553
+ if (!isRecord2(value) || value.profile !== "inline-base64" && value.profile !== "url-session") {
2554
+ throw new TopoloSdkError("upload_contract_unavailable", "This action does not publish a supported upload contract.");
2555
+ }
2556
+ const contentTypeField = requiredField(value.contentTypeField, "contentTypeField");
2557
+ const fileNameField = requiredField(value.fileNameField, "fileNameField");
2558
+ const maxBytes = Number(value.maxBytes);
2559
+ const acceptedContentTypes = Array.isArray(value.acceptedContentTypes) ? value.acceptedContentTypes.map((item) => String(item).trim().toLowerCase()).filter(Boolean) : [];
2560
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || acceptedContentTypes.length === 0) {
2561
+ throw new TopoloSdkError("invalid_upload_contract", "The action upload contract must declare maxBytes and acceptedContentTypes.");
2562
+ }
2563
+ if (value.profile === "inline-base64") {
2564
+ return {
2565
+ profile: "inline-base64",
2566
+ contentField: requiredField(value.contentField, "contentField"),
2567
+ contentTypeField,
2568
+ fileNameField,
2569
+ maxBytes,
2570
+ acceptedContentTypes
2571
+ };
2572
+ }
2573
+ const uploadMethod = value.uploadMethod === "PUT" || value.uploadMethod === "POST" ? value.uploadMethod : null;
2574
+ const uploadUrlPath = requiredPath(value.uploadUrlPath, "uploadUrlPath");
2575
+ const complete = value.complete === void 0 ? void 0 : parseCompleteContract(value.complete);
2576
+ if (!uploadMethod) throw new TopoloSdkError("invalid_upload_contract", "The action upload contract has an invalid uploadMethod.");
2577
+ return {
2578
+ profile: "url-session",
2579
+ fileNameField,
2580
+ contentTypeField,
2581
+ sizeField: requiredField(value.sizeField, "sizeField"),
2582
+ maxBytes,
2583
+ acceptedContentTypes,
2584
+ uploadUrlPath,
2585
+ uploadMethod,
2586
+ ...complete ? { complete } : {}
2587
+ };
2588
+ }
2589
+ function prepareTopoloActionUploadInput(contractValue, content, input, options) {
2590
+ const contract = parseTopoloActionUploadContract(contractValue);
2591
+ if (contract.profile !== "inline-base64") throw unsupportedInlineProfile();
2592
+ const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
2593
+ if (bytes.byteLength === 0) {
2594
+ throw new TopoloSdkError("invalid_upload_content", "Upload content must not be empty.");
2595
+ }
2596
+ if (bytes.byteLength > contract.maxBytes) {
2597
+ throw new TopoloSdkError("upload_too_large", `Upload content exceeds the ${contract.maxBytes}-byte action limit.`);
2598
+ }
2599
+ return mapUploadInput(contract, bytesToBase64(bytes), input, options);
2600
+ }
2601
+ function prepareTopoloActionBase64UploadInput(contractValue, contentBase64, input, options) {
2602
+ const contract = parseTopoloActionUploadContract(contractValue);
2603
+ if (contract.profile !== "inline-base64") throw unsupportedInlineProfile();
2604
+ if (!isCanonicalBase64(contentBase64)) {
2605
+ throw new TopoloSdkError("invalid_upload_content", "Upload content must be canonical base64.");
2606
+ }
2607
+ const padding = contentBase64.endsWith("==") ? 2 : contentBase64.endsWith("=") ? 1 : 0;
2608
+ const byteLength = contentBase64.length / 4 * 3 - padding;
2609
+ if (byteLength === 0) throw new TopoloSdkError("invalid_upload_content", "Upload content must not be empty.");
2610
+ if (byteLength > contract.maxBytes) {
2611
+ throw new TopoloSdkError("upload_too_large", `Upload content exceeds the ${contract.maxBytes}-byte action limit.`);
2612
+ }
2613
+ return mapUploadInput(contract, contentBase64, input, options);
2614
+ }
2615
+ function prepareTopoloUrlSessionUploadInput(contractValue, byteLength, input, options) {
2616
+ const contract = parseTopoloActionUploadContract(contractValue);
2617
+ if (contract.profile !== "url-session") {
2618
+ throw new TopoloSdkError("invalid_upload_contract", "This action does not publish a URL-session upload contract.");
2619
+ }
2620
+ validateUploadMetadata(contract, byteLength, options);
2621
+ return {
2622
+ contract,
2623
+ input: {
2624
+ ...input,
2625
+ [contract.fileNameField]: options.fileName.trim(),
2626
+ [contract.contentTypeField]: options.contentType.trim().toLowerCase(),
2627
+ [contract.sizeField]: byteLength
2628
+ }
2629
+ };
2630
+ }
2631
+ function topoloUploadResponseValue(response, path) {
2632
+ return path.split(".").reduce((value, segment) => isRecord2(value) ? value[segment] : void 0, response);
2633
+ }
2634
+ function decodeTopoloActionBase64(contentBase64) {
2635
+ if (!isCanonicalBase64(contentBase64)) {
2636
+ throw new TopoloSdkError("invalid_upload_content", "Upload content must be canonical base64.");
2637
+ }
2638
+ const binary = atob(contentBase64);
2639
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
2640
+ }
2641
+ function mapUploadInput(contract, contentBase64, input, options) {
2642
+ validateUploadMetadata(contract, Math.max(1, decodedBase64Size(contentBase64)), options);
2643
+ const fileName = options.fileName.trim();
2644
+ const contentType = options.contentType.trim().toLowerCase();
2645
+ return {
2646
+ ...input,
2647
+ [contract.contentField]: contentBase64,
2648
+ [contract.contentTypeField]: contentType,
2649
+ [contract.fileNameField]: fileName
2650
+ };
2651
+ }
2652
+ function validateUploadMetadata(contract, byteLength, options) {
2653
+ const fileName = options.fileName.trim();
2654
+ const contentType = options.contentType.trim().toLowerCase();
2655
+ if (!fileName) throw new TopoloSdkError("invalid_upload_file_name", "Upload fileName must not be empty.");
2656
+ if (!Number.isSafeInteger(byteLength) || byteLength <= 0) {
2657
+ throw new TopoloSdkError("invalid_upload_content", "Upload content must not be empty.");
2658
+ }
2659
+ if (byteLength > contract.maxBytes) {
2660
+ throw new TopoloSdkError("upload_too_large", `Upload content exceeds the ${contract.maxBytes}-byte action limit.`);
2661
+ }
2662
+ if (!matchesContentType(contentType, contract.acceptedContentTypes)) {
2663
+ throw new TopoloSdkError("unsupported_upload_content_type", `Content type "${contentType}" is not accepted by this action.`);
2664
+ }
2665
+ }
2666
+ function decodedBase64Size(contentBase64) {
2667
+ const padding = contentBase64.endsWith("==") ? 2 : contentBase64.endsWith("=") ? 1 : 0;
2668
+ return contentBase64.length / 4 * 3 - padding;
2669
+ }
2670
+ function parseCompleteContract(value) {
2671
+ if (!isRecord2(value)) throw new TopoloSdkError("invalid_upload_contract", "The action upload completion contract is invalid.");
2672
+ return {
2673
+ actionId: requiredPath(value.actionId, "complete.actionId"),
2674
+ inputField: requiredField(value.inputField, "complete.inputField"),
2675
+ responsePath: requiredPath(value.responsePath, "complete.responsePath")
2676
+ };
2677
+ }
2678
+ function requiredPath(value, name) {
2679
+ const path = typeof value === "string" ? value.trim() : "";
2680
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(path)) {
2681
+ throw new TopoloSdkError("invalid_upload_contract", `The action upload contract has an invalid ${name}.`);
2682
+ }
2683
+ return path;
2684
+ }
2685
+ function unsupportedInlineProfile() {
2686
+ return new TopoloSdkError("invalid_upload_contract", "This action does not publish an inline-base64 upload contract.");
2687
+ }
2688
+ function isCanonicalBase64(value) {
2689
+ return value.length > 0 && value.length % 4 === 0 && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
2690
+ }
2691
+ function requiredField(value, name) {
2692
+ const field = typeof value === "string" ? value.trim() : "";
2693
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(field)) {
2694
+ throw new TopoloSdkError("invalid_upload_contract", `The action upload contract has an invalid ${name}.`);
2695
+ }
2696
+ return field;
2697
+ }
2698
+ function matchesContentType(contentType, accepted) {
2699
+ return accepted.some((candidate) => candidate === "*/*" || candidate === contentType || candidate.endsWith("/*") && contentType.startsWith(candidate.slice(0, -1)));
2700
+ }
2701
+ function bytesToBase64(bytes) {
2702
+ let binary = "";
2703
+ const chunkSize = 32768;
2704
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
2705
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
2706
+ }
2707
+ return btoa(binary);
2708
+ }
2709
+ function isRecord2(value) {
2710
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
2711
+ }
2712
+
2501
2713
  // src/client.ts
2502
2714
  var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
2503
2715
  var TOPOLO_ACTION_ID_HEADER = "X-Topolo-Action-Id";
@@ -2575,6 +2787,9 @@ var TopoloClient = class _TopoloClient {
2575
2787
  if (v !== void 0) url.searchParams.set(k, String(v));
2576
2788
  }
2577
2789
  }
2790
+ if (opts.body !== void 0 && opts.rawBody !== void 0) {
2791
+ throw new TopoloSdkError("invalid_request_body", "body and rawBody are mutually exclusive.");
2792
+ }
2578
2793
  const headers = new Headers();
2579
2794
  headers.set("Accept", "application/json");
2580
2795
  if (opts.body !== void 0) headers.set("Content-Type", "application/json");
@@ -2609,7 +2824,7 @@ var TopoloClient = class _TopoloClient {
2609
2824
  return await this.fetchImpl(urlStr, {
2610
2825
  method,
2611
2826
  headers: new Headers(headers),
2612
- body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
2827
+ body: opts.rawBody ?? (opts.body !== void 0 ? JSON.stringify(opts.body) : null),
2613
2828
  signal
2614
2829
  });
2615
2830
  } finally {
@@ -2657,14 +2872,32 @@ var TopoloClient = class _TopoloClient {
2657
2872
  error: message,
2658
2873
  status: res.status
2659
2874
  });
2660
- if (res.status === 401) throw new TopoloAuthError(describeError(parsed, "Unauthorized"));
2875
+ const errorContext = {
2876
+ status: res.status,
2877
+ body: parsed,
2878
+ service: resolvedService.serviceKey,
2879
+ path: opts.path,
2880
+ requestId: res.headers.get("X-Topolo-Request-Id") || requestId,
2881
+ retryAfter: res.headers.get("Retry-After") || void 0
2882
+ };
2883
+ if (res.status === 401) {
2884
+ throw new TopoloAuthError(describeError(parsed, "Unauthorized"), "auth_error", errorContext);
2885
+ }
2661
2886
  if (res.status === 403) {
2662
2887
  throw new TopoloPermissionError(
2663
2888
  describeError(parsed, "Permission denied"),
2664
- extractRequired(parsed)
2889
+ extractRequired(parsed),
2890
+ errorContext
2665
2891
  );
2666
2892
  }
2667
- throw new TopoloHttpError(resolvedService.serviceKey, opts.path, res.status, parsed);
2893
+ throw new TopoloHttpError(
2894
+ resolvedService.serviceKey,
2895
+ opts.path,
2896
+ res.status,
2897
+ parsed,
2898
+ message,
2899
+ errorContext
2900
+ );
2668
2901
  }
2669
2902
  this.emit({
2670
2903
  phase: "response",
@@ -2823,7 +3056,7 @@ var TopoloClient = class _TopoloClient {
2823
3056
  }
2824
3057
  const entries = [];
2825
3058
  for (const item of raw) {
2826
- if (!isRecord2(item)) continue;
3059
+ if (!isRecord3(item)) continue;
2827
3060
  const s = item;
2828
3061
  const appId = typeof s.app_id === "string" ? s.app_id : typeof s.id === "string" ? s.id : null;
2829
3062
  if (!appId) continue;
@@ -2898,8 +3131,8 @@ var TopoloClient = class _TopoloClient {
2898
3131
  "app_topolo_auth.service_api_key_resources.list",
2899
3132
  { appId: service.appId }
2900
3133
  );
2901
- const payload = isRecord2(response) && isRecord2(response.data) ? response.data : response;
2902
- if (!isRecord2(payload) || !Array.isArray(payload.resources)) {
3134
+ const payload = isRecord3(response) && isRecord3(response.data) ? response.data : response;
3135
+ if (!isRecord3(payload) || !Array.isArray(payload.resources)) {
2903
3136
  throw new TopoloSdkError("resource_catalog_unavailable", "TopoloAuth did not return bindable resources.");
2904
3137
  }
2905
3138
  return {
@@ -3005,6 +3238,71 @@ var TopoloClient = class _TopoloClient {
3005
3238
  const entry = await this.getAction(action);
3006
3239
  return this.executeResolvedAction(entry, input, options);
3007
3240
  }
3241
+ async uploadAction(action, content, input, options) {
3242
+ const entry = await this.getAction(action);
3243
+ const contract = parseTopoloActionUploadContract(entry.agentContract?.upload);
3244
+ if (contract.profile === "url-session") {
3245
+ const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
3246
+ return this.executeUrlSessionUpload(entry, contract, bytes, input, options);
3247
+ }
3248
+ const uploadInput = prepareTopoloActionUploadInput(entry.agentContract?.upload, content, input, options);
3249
+ return this.executeResolvedAction(entry, uploadInput, options);
3250
+ }
3251
+ async uploadActionBase64(action, contentBase64, input, options) {
3252
+ const entry = await this.getAction(action);
3253
+ const contract = parseTopoloActionUploadContract(entry.agentContract?.upload);
3254
+ if (contract.profile === "url-session") {
3255
+ return this.executeUrlSessionUpload(entry, contract, decodeTopoloActionBase64(contentBase64), input, options);
3256
+ }
3257
+ const uploadInput = prepareTopoloActionBase64UploadInput(entry.agentContract?.upload, contentBase64, input, options);
3258
+ return this.executeResolvedAction(entry, uploadInput, options);
3259
+ }
3260
+ async executeUrlSessionUpload(entry, contract, bytes, input, options) {
3261
+ const prepared = prepareTopoloUrlSessionUploadInput(contract, bytes.byteLength, input, options);
3262
+ const initiation = await this.executeResolvedAction(entry, prepared.input, options);
3263
+ const uploadUrl = topoloUploadResponseValue(initiation, contract.uploadUrlPath);
3264
+ if (typeof uploadUrl !== "string" || !uploadUrl.trim()) {
3265
+ throw new TopoloSdkError("invalid_upload_response", `Upload action response is missing ${contract.uploadUrlPath}.`);
3266
+ }
3267
+ await this.putUrlSessionContent(entry, uploadUrl, contract.uploadMethod, bytes, options);
3268
+ if (!contract.complete) return initiation;
3269
+ const completionValue = topoloUploadResponseValue(initiation, contract.complete.responsePath);
3270
+ if (typeof completionValue !== "string" || !completionValue.trim()) {
3271
+ throw new TopoloSdkError("invalid_upload_response", `Upload action response is missing ${contract.complete.responsePath}.`);
3272
+ }
3273
+ const completion = await this.getAction(contract.complete.actionId);
3274
+ return this.executeResolvedAction(completion, {
3275
+ ...options.completionInput ?? {},
3276
+ [contract.complete.inputField]: completionValue
3277
+ }, options);
3278
+ }
3279
+ async putUrlSessionContent(entry, uploadUrl, method, bytes, options) {
3280
+ const service = entry.appSlug || entry.appId;
3281
+ const resolved = entry.apiBaseUrl ? { serviceKey: normalizeLookupKey(service), baseUrl: requireHttpOrigin(entry.apiBaseUrl) } : await this.resolveRequestService(service);
3282
+ const target = new URL(uploadUrl, resolved.baseUrl.endsWith("/") ? resolved.baseUrl : `${resolved.baseUrl}/`);
3283
+ const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
3284
+ if (target.origin === new URL(resolved.baseUrl).origin) {
3285
+ await this.request({
3286
+ service,
3287
+ baseUrl: resolved.baseUrl,
3288
+ path: target.toString(),
3289
+ method,
3290
+ rawBody: body,
3291
+ headers: { "Content-Type": options.contentType.trim().toLowerCase() },
3292
+ resource: options.resource,
3293
+ confirm: options.confirm
3294
+ });
3295
+ return;
3296
+ }
3297
+ const response = await this.fetchImpl(target, {
3298
+ method,
3299
+ headers: { "Content-Type": options.contentType.trim().toLowerCase() },
3300
+ body
3301
+ });
3302
+ if (!response.ok) {
3303
+ throw new TopoloHttpError(service, target.pathname, response.status, await response.text(), `Upload transport failed with HTTP ${response.status}.`);
3304
+ }
3305
+ }
3008
3306
  async validateActionInput(action, input = {}) {
3009
3307
  const entry = await this.getAction(action);
3010
3308
  return { action: entry, validation: validateTopoloActionInput(entry.inputSchema, input) };
@@ -3163,7 +3461,7 @@ function resolveConfiguredAppId(slug, appIds) {
3163
3461
  return null;
3164
3462
  }
3165
3463
  function normalizeApiKeyResourceMetadata(input) {
3166
- if (!isRecord2(input)) return null;
3464
+ if (!isRecord3(input)) return null;
3167
3465
  const resourceType = readString(input.resourceType ?? input.resource_type);
3168
3466
  const label = readString(input.label);
3169
3467
  const emptyLabel = readString(input.emptyLabel ?? input.empty_label);
@@ -3197,7 +3495,7 @@ function normalizeApiKeyResourceMetadataList(input) {
3197
3495
  return resources;
3198
3496
  }
3199
3497
  function normalizeBindableResource(input) {
3200
- if (!isRecord2(input)) return null;
3498
+ if (!isRecord3(input)) return null;
3201
3499
  const resourceType = readString(input.resourceType ?? input.resource_type);
3202
3500
  const resourceId = readString(input.resourceId ?? input.resource_id);
3203
3501
  if (!resourceType || !resourceId) return null;
@@ -3208,13 +3506,13 @@ function normalizeBindableResource(input) {
3208
3506
  slug: readOptionalString(input.slug),
3209
3507
  status: readOptionalString(input.status),
3210
3508
  organizationId: readOptionalString(input.organizationId ?? input.organization_id),
3211
- metadata: isRecord2(input.metadata) ? input.metadata : {}
3509
+ metadata: isRecord3(input.metadata) ? input.metadata : {}
3212
3510
  };
3213
3511
  }
3214
3512
  function normalizeResourceBindings(input) {
3215
3513
  if (!Array.isArray(input)) return [];
3216
3514
  return input.flatMap((binding) => {
3217
- if (!isRecord2(binding)) return [];
3515
+ if (!isRecord3(binding)) return [];
3218
3516
  const resourceType = readString(binding.resourceType ?? binding.resource_type);
3219
3517
  const resourceId = readString(binding.resourceId ?? binding.resource_id);
3220
3518
  return resourceType && resourceId ? [{ resourceType, resourceId }] : [];
@@ -3227,7 +3525,7 @@ function readOptionalString(value) {
3227
3525
  const normalized = readString(value);
3228
3526
  return normalized || null;
3229
3527
  }
3230
- function isRecord2(value) {
3528
+ function isRecord3(value) {
3231
3529
  return typeof value === "object" && value !== null && !Array.isArray(value);
3232
3530
  }
3233
3531
  function resolveConfiguredServiceUrl(slug, serviceUrls) {
@@ -3299,14 +3597,14 @@ function catalogSearchQuery(options) {
3299
3597
  return query;
3300
3598
  }
3301
3599
  function normalizeCatalogPage(value) {
3302
- if (!isRecord2(value)) return { nextCursor: null, hasMore: false };
3600
+ if (!isRecord3(value)) return { nextCursor: null, hasMore: false };
3303
3601
  return {
3304
3602
  nextCursor: typeof value.nextCursor === "string" && value.nextCursor ? value.nextCursor : null,
3305
3603
  hasMore: value.hasMore === true
3306
3604
  };
3307
3605
  }
3308
3606
  function normalizeServiceCatalogEntry(item) {
3309
- if (!isRecord2(item)) return null;
3607
+ if (!isRecord3(item)) return null;
3310
3608
  const appId = typeof item.app_id === "string" ? item.app_id : typeof item.id === "string" ? item.id : null;
3311
3609
  if (!appId) return null;
3312
3610
  const permissions = Array.isArray(item.granted_permissions) ? item.granted_permissions.filter(
@@ -3564,7 +3862,7 @@ var INVERSE_OPERATIONS = /* @__PURE__ */ new Map([
3564
3862
  ["restore", "archive"],
3565
3863
  ["unpublish", "publish"]
3566
3864
  ]);
3567
- function isRecord3(value) {
3865
+ function isRecord4(value) {
3568
3866
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
3569
3867
  }
3570
3868
  function nonEmptyStrings(value) {
@@ -3574,9 +3872,15 @@ function slugify(value) {
3574
3872
  return String(value || "").trim().replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3575
3873
  }
3576
3874
  function singularize(value) {
3875
+ const irregular = /* @__PURE__ */ new Map([
3876
+ ["analyses", "analysis"],
3877
+ ["buses", "bus"],
3878
+ ["statuses", "status"]
3879
+ ]);
3880
+ if (irregular.has(value)) return irregular.get(value);
3577
3881
  if (value.endsWith("ies") && value.length > 3) return `${value.slice(0, -3)}y`;
3578
- if (value.endsWith("sses")) return value.slice(0, -2);
3579
- if (value.endsWith("s") && !value.endsWith("ss")) return value.slice(0, -1);
3882
+ if (/(?:ches|shes|sses|xes|zes)$/.test(value)) return value.slice(0, -2);
3883
+ if (value.endsWith("s") && !/(?:ss|us|is)$/.test(value)) return value.slice(0, -1);
3580
3884
  return value || "resource";
3581
3885
  }
3582
3886
  function operationFor(action) {
@@ -3590,11 +3894,16 @@ function resourceFor(action) {
3590
3894
  const pathToken = String(action.path || "").split("/").filter((token) => token && token !== "api" && !token.startsWith("{")).find((token) => !OPERATION_TOKENS.has(slugify(token)));
3591
3895
  return singularize(slugify(pathToken || "resource").replaceAll("-", "_"));
3592
3896
  }
3897
+ function collectionFor(action, resource) {
3898
+ const named = String(action.name || "").split(/[._:-]+/).filter(Boolean).find((token) => !OPERATION_TOKENS.has(token));
3899
+ if (named) return slugify(named).replaceAll("-", "_");
3900
+ return resource.endsWith("s") ? resource : `${resource}s`;
3901
+ }
3593
3902
  function isConcreteOutputSchema(schema) {
3594
- if (!isRecord3(schema)) return false;
3903
+ if (!isRecord4(schema)) return false;
3595
3904
  if (typeof schema.$ref === "string" && schema.$ref.trim()) return true;
3596
- if (schema.type === "array" && isRecord3(schema.items) && Object.keys(schema.items).length > 0) return true;
3597
- if (isRecord3(schema.properties) && Object.keys(schema.properties).length > 0) return true;
3905
+ if (schema.type === "array" && isRecord4(schema.items) && Object.keys(schema.items).length > 0) return true;
3906
+ if (isRecord4(schema.properties) && Object.keys(schema.properties).length > 0) return true;
3598
3907
  return ["oneOf", "anyOf", "allOf"].some((key) => Array.isArray(schema[key]) && schema[key].some(isConcreteOutputSchema));
3599
3908
  }
3600
3909
  function resourceSchema(resource) {
@@ -3620,6 +3929,7 @@ function derivedOutputSchema(action) {
3620
3929
  "x-topolo-contract-precision": "structural"
3621
3930
  };
3622
3931
  if (operation === "list" || operation === "search") {
3932
+ const collection = collectionFor(action, resource);
3623
3933
  return {
3624
3934
  title: `${action.title || action.name} response`,
3625
3935
  description: `Collection response returned by ${action.name}. The service may return the collection directly or in an envelope.`,
@@ -3630,7 +3940,7 @@ function derivedOutputSchema(action) {
3630
3940
  properties: {
3631
3941
  data: { oneOf: [records, { type: "object", additionalProperties: true }] },
3632
3942
  items: records,
3633
- [resource.endsWith("s") ? resource : `${resource}s`]: records,
3943
+ [collection]: records,
3634
3944
  count: { type: "integer", minimum: 0 },
3635
3945
  nextCursor: { type: ["string", "null"] }
3636
3946
  },
@@ -3674,7 +3984,7 @@ function derivedOutputSchema(action) {
3674
3984
  };
3675
3985
  }
3676
3986
  function sampleForSchema(schema, depth = 0) {
3677
- if (!isRecord3(schema) || depth > 5) return null;
3987
+ if (!isRecord4(schema) || depth > 5) return null;
3678
3988
  if (schema.const !== void 0) return schema.const;
3679
3989
  if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0];
3680
3990
  if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return sampleForSchema(schema.oneOf[0], depth + 1);
@@ -3688,8 +3998,8 @@ function sampleForSchema(schema, depth = 0) {
3688
3998
  return schema.minLength > 1 ? "example".padEnd(schema.minLength, "x") : "example";
3689
3999
  }
3690
4000
  if (schema.type === "null") return null;
3691
- if (schema.type === "object" || isRecord3(schema.properties)) {
3692
- const properties = isRecord3(schema.properties) ? schema.properties : {};
4001
+ if (schema.type === "object" || isRecord4(schema.properties)) {
4002
+ const properties = isRecord4(schema.properties) ? schema.properties : {};
3693
4003
  const required = new Set(Array.isArray(schema.required) ? schema.required : []);
3694
4004
  const entries = Object.entries(properties).filter(([key]) => required.has(key)).map(([key, child]) => [key, sampleForSchema(child, depth + 1)]);
3695
4005
  return Object.fromEntries(entries);
@@ -3739,21 +4049,32 @@ function defaultErrors(action) {
3739
4049
  const errors = [
3740
4050
  { status: 401, code: "authentication_required", recovery: "Authenticate again or provide a valid Topolo API key." },
3741
4051
  { status: 403, code: "permission_denied", recovery: `Grant the credential permission ${action.permission || action.permissionName || "required by this action"} and the required resource binding.` },
3742
- { status: 422, code: "invalid_input", recovery: "Correct the payload using inputSchema, then validate before retrying." }
4052
+ { status: 429, code: "rate_limited", recovery: "Wait for Retry-After when present, then retry without parallel duplicate calls." },
4053
+ { status: 422, code: "invalid_input", recovery: "Correct the payload using inputSchema, then validate before retrying." },
4054
+ { status: 503, code: "dependency_unavailable", recovery: "Retry the action. If it continues, inspect the structured error details using the request ID." }
3743
4055
  ];
3744
4056
  if (action.method !== "GET") {
3745
4057
  errors.push({ status: 409, code: "state_conflict", recovery: "Read the current resource state, reconcile the intended change, and retry only if still valid." });
3746
4058
  }
3747
4059
  return errors;
3748
4060
  }
4061
+ function mergeErrors(defaults, declared) {
4062
+ const merged = [...defaults];
4063
+ for (const error of Array.isArray(declared) ? declared : []) {
4064
+ const index = merged.findIndex((candidate) => candidate.status === error?.status && candidate.code === error?.code);
4065
+ if (index === -1) merged.push(error);
4066
+ else merged[index] = error;
4067
+ }
4068
+ return merged;
4069
+ }
3749
4070
  function enrichActionContract(manifest, action, allActions = manifest.actions || []) {
3750
4071
  const appId = String(manifest.appId || manifest.app_id || "").trim();
3751
4072
  const service = slugify(manifest.slug || appId.replace(/^app_/, "").replaceAll("_", "-") || manifest.name);
3752
4073
  const outputSchema = isConcreteOutputSchema(action.outputSchema) ? structuredClone(action.outputSchema) : derivedOutputSchema(action);
3753
4074
  const resource = resourceFor(action);
3754
4075
  const readOnly = action.readOnly !== void 0 ? action.readOnly === true : action.method === "GET";
3755
- const existing = isRecord3(action.agentContract) ? structuredClone(action.agentContract) : {};
3756
- const recovery = isRecord3(existing.recovery) ? existing.recovery : recoveryFor(action, allActions, appId);
4076
+ const existing = isRecord4(action.agentContract) ? structuredClone(action.agentContract) : {};
4077
+ const recovery = isRecord4(existing.recovery) ? existing.recovery : recoveryFor(action, allActions, appId);
3757
4078
  const rollbackActionId = typeof existing.rollbackActionId === "string" && existing.rollbackActionId.trim() ? existing.rollbackActionId.trim() : recovery.mode === "compensating_action" ? recovery.actionId : void 0;
3758
4079
  const inputExample = sampleForSchema(action.inputSchema);
3759
4080
  const examples = Array.isArray(existing.examples) && existing.examples.length > 0 ? existing.examples : [{ input: inputExample ?? {}, expected: `A response matching the published outputSchema for ${action.name}.` }];
@@ -3765,9 +4086,9 @@ function enrichActionContract(manifest, action, allActions = manifest.actions ||
3765
4086
  ...existing,
3766
4087
  docsUrl,
3767
4088
  ...existingDocsUrl && existingDocsUrl !== docsUrl ? { guideUrl: existingDocsUrl } : {},
3768
- resource: isRecord3(existing.resource) ? existing.resource : { kind: resource, idPath: readOnly && ["list", "search"].includes(operationFor(action)) ? "$.items[].id" : "$.id" },
4089
+ resource: isRecord4(existing.resource) ? existing.resource : { kind: resource, idPath: readOnly && ["list", "search"].includes(operationFor(action)) ? "$.items[].id" : "$.id" },
3769
4090
  effects: nonEmptyStrings(existing.effects) ? existing.effects : [readOnly ? `Reads ${resource.replaceAll("_", " ")} data without changing service state.` : action.destructive === true || action.method === "DELETE" ? `Permanently removes or invalidates ${resource.replaceAll("_", " ")} state.` : `Changes ${resource.replaceAll("_", " ")} state in the selected Topolo resource context.`],
3770
- errors: Array.isArray(existing.errors) && existing.errors.length > 0 ? existing.errors : defaultErrors(action),
4091
+ errors: mergeErrors(defaultErrors(action), existing.errors),
3771
4092
  verify: nonEmptyStrings(existing.verify) ? existing.verify : [`Validate the response against outputSchema and ${readOnly ? "confirm it belongs to the selected resource context" : "re-read the affected resource to confirm the intended state"}.`],
3772
4093
  examples,
3773
4094
  recovery,
@@ -3779,15 +4100,15 @@ function enrichActionContract(manifest, action, allActions = manifest.actions ||
3779
4100
  function actionContractCompleteness(action) {
3780
4101
  const contract = action?.agentContract;
3781
4102
  return {
3782
- inputSchema: isRecord3(action?.inputSchema),
4103
+ inputSchema: isRecord4(action?.inputSchema),
3783
4104
  outputSchema: isConcreteOutputSchema(action?.outputSchema),
3784
4105
  effects: nonEmptyStrings(contract?.effects),
3785
4106
  errors: Array.isArray(contract?.errors) && contract.errors.length > 0,
3786
4107
  docs: typeof contract?.docsUrl === "string" && contract.docsUrl.startsWith("https://"),
3787
4108
  examples: Array.isArray(contract?.examples) && contract.examples.length > 0,
3788
4109
  verification: nonEmptyStrings(contract?.verify),
3789
- recovery: isRecord3(contract?.recovery) && ["not_applicable", "compensating_action", "irreversible"].includes(contract.recovery.mode),
3790
- agentContract: isRecord3(contract) && isRecord3(contract.resource) && nonEmptyStrings(contract.effects) && Array.isArray(contract.errors) && contract.errors.length > 0
4110
+ recovery: isRecord4(contract?.recovery) && ["not_applicable", "compensating_action", "irreversible"].includes(contract.recovery.mode),
4111
+ agentContract: isRecord4(contract) && isRecord4(contract.resource) && nonEmptyStrings(contract.effects) && Array.isArray(contract.errors) && contract.errors.length > 0
3791
4112
  };
3792
4113
  }
3793
4114
 
@@ -4192,7 +4513,7 @@ function createTopoloWidgetError(error) {
4192
4513
  }
4193
4514
  function validateTopoloWidgetResponse(response) {
4194
4515
  const errors = [];
4195
- if (!isRecord4(response)) {
4516
+ if (!isRecord5(response)) {
4196
4517
  return { ok: false, errors: ["response must be an object"] };
4197
4518
  }
4198
4519
  if (typeof response.success !== "boolean") {
@@ -4211,7 +4532,7 @@ function validateTopoloWidgetResponse(response) {
4211
4532
  return { ok: errors.length === 0, errors };
4212
4533
  }
4213
4534
  function validateWidget(widget, path, errors) {
4214
- if (!isRecord4(widget)) {
4535
+ if (!isRecord5(widget)) {
4215
4536
  errors.push(`${path} must be an object`);
4216
4537
  return;
4217
4538
  }
@@ -4238,7 +4559,7 @@ function validateWidget(widget, path, errors) {
4238
4559
  case "chart":
4239
4560
  validateNonEmptyString(widget.chartType, `${path}.chartType`, errors);
4240
4561
  validateNonEmptyString(widget.title, `${path}.title`, errors);
4241
- if (!isRecord4(widget.data)) {
4562
+ if (!isRecord5(widget.data)) {
4242
4563
  errors.push(`${path}.data must be an object`);
4243
4564
  } else {
4244
4565
  validateArray2(widget.data.labels, `${path}.data.labels`, errors);
@@ -4254,7 +4575,7 @@ function validateSnapshotMetadata(snapshot, path, errors) {
4254
4575
  errors.push(`${path} must be an object`);
4255
4576
  return;
4256
4577
  }
4257
- if (!isRecord4(snapshot)) {
4578
+ if (!isRecord5(snapshot)) {
4258
4579
  errors.push(`${path} must be an object`);
4259
4580
  return;
4260
4581
  }
@@ -4274,7 +4595,7 @@ function validateSnapshotMetadata(snapshot, path, errors) {
4274
4595
  }
4275
4596
  snapshot.availableMetrics.forEach((metric, index) => {
4276
4597
  const metricPath = `${path}.availableMetrics[${index}]`;
4277
- if (!isRecord4(metric)) {
4598
+ if (!isRecord5(metric)) {
4278
4599
  errors.push(`${metricPath} must be an object`);
4279
4600
  return;
4280
4601
  }
@@ -4295,7 +4616,7 @@ function validateArray2(value, path, errors) {
4295
4616
  errors.push(`${path} must be an array`);
4296
4617
  }
4297
4618
  }
4298
- function isRecord4(value) {
4619
+ function isRecord5(value) {
4299
4620
  return typeof value === "object" && value !== null && !Array.isArray(value);
4300
4621
  }
4301
4622
  function toIsoTimestamp(input) {
@@ -4347,6 +4668,9 @@ function createTopolo(options) {
4347
4668
  defineTopoloStatsWidget,
4348
4669
  digestTopoloActionCatalog,
4349
4670
  enrichActionContract,
4671
+ parseTopoloActionUploadContract,
4672
+ prepareTopoloActionBase64UploadInput,
4673
+ prepareTopoloActionUploadInput,
4350
4674
  requirementsForApplication,
4351
4675
  resolveCatalogServiceUrl,
4352
4676
  resolveServiceUrl,