@topolo/sdk 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,15 @@ const topolo = createTopolo({
35
35
  const me = await topolo.identity.whoami();
36
36
  console.log(me.organization?.slug);
37
37
 
38
- const { contacts } = await topolo.crm.listContacts({ q: 'acme' });
38
+ const actions = await topolo.client.listActions({ service: 'mail' });
39
+ const sendMessage = actions.find((action) => action.name === 'messages.send');
40
+ if (sendMessage) {
41
+ await topolo.client.callAction(sendMessage.actionId, {
42
+ mailboxId: 'primary',
43
+ subject: 'Hello',
44
+ text: '...',
45
+ }, { confirm: true });
46
+ }
39
47
  ```
40
48
 
41
49
  ## Design invariants
@@ -73,14 +81,16 @@ Every request sends:
73
81
  Combined with the credential identity, this makes every platform API call
74
82
  traceable to a specific agent invocation.
75
83
 
76
- ## Modules (Phase 1)
84
+ ## Modules
77
85
 
78
- | Module | Methods |
86
+ | Surface | Methods |
79
87
  | ------------ | ------------------------------------- |
80
88
  | `identity` | `whoami()` |
81
- | `crm` | `listContacts()`, `getContact(id)` |
89
+ | `client` | `listServices()`, `listActions()`, `getAction()`, `callAction()` |
90
+ | app modules | compatibility helpers for stable app-specific contracts |
82
91
 
83
- Additional modules land per-app as each backend contract stabilizes.
92
+ Agents should use the action catalog for app-specific operations so new
93
+ services and actions become usable without an SDK, CLI, or MCP release.
84
94
 
85
95
  ## Application catalog
86
96
 
@@ -99,6 +109,20 @@ bundled service ID is only a convenient alias after Auth confirms access.
99
109
  `listBundledServices()` is available for packaging/audit tooling that needs the
100
110
  generated snapshot. Do not use it to decide what an end user or agent may call.
101
111
 
112
+ ## Action catalog
113
+
114
+ `TopoloClient.listActions()` reads TopoloAuth's live action catalog for the
115
+ current credential. Applications publish their action definitions through
116
+ Topolo Developers, and Auth returns only actions whose existing service
117
+ permission is granted to the credential. The action entry includes the service,
118
+ HTTP method/path, required permission, input/output schemas, read-only and
119
+ destructive hints, and the stable MCP/agent tool name.
120
+
121
+ `TopoloClient.callAction(actionRef, input, options)` resolves an action by
122
+ `actionId`, `name`, or `toolName`, maps path parameters and query/body values
123
+ from `input`, and routes the request through the same auth, audit-header, and
124
+ write-confirmation flow as `request()`.
125
+
102
126
  `APPLICATION_REQUIREMENTS` is the versioned app-build contract agents should
103
127
  read before creating or expanding a Topolo app. `requirementsForApplication()`
104
128
  filters the shared contract to the browser/API/tooling scopes implied by the app
@@ -162,12 +186,12 @@ Defaults target production. Override per-service for staging/dev:
162
186
  createTopolo({
163
187
  /* ... */
164
188
  serviceUrls: {
165
- crm: 'https://edge-crm-stg.topolo.workers.dev',
189
+ mail: 'https://mail.stg.topolo.us',
166
190
  },
167
191
  });
168
192
  ```
169
193
 
170
- Or via env: `TOPOLO_SERVICE_URL_CRM=...` (falls through `resolveServiceUrl`).
194
+ Or via env: `TOPOLO_SERVICE_URL_MAIL=...` (falls through `resolveServiceUrl`).
171
195
 
172
196
  ## Development
173
197
 
package/dist/index.cjs CHANGED
@@ -2123,6 +2123,7 @@ var TopoloClient = class _TopoloClient {
2123
2123
  fetchImpl;
2124
2124
  debug;
2125
2125
  serviceCatalogCache = null;
2126
+ actionCatalogCache = null;
2126
2127
  /**
2127
2128
  * Process-wide cache for slug → service-id lookups. Keyed by
2128
2129
  * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
@@ -2383,6 +2384,63 @@ var TopoloClient = class _TopoloClient {
2383
2384
  };
2384
2385
  return entries;
2385
2386
  }
2387
+ /**
2388
+ * List callable actions from the live Auth catalog. The response is scoped to
2389
+ * the caller's organization and effective permissions; no bundled/static app
2390
+ * action list is used as an authorization source.
2391
+ */
2392
+ async listActions(options = {}) {
2393
+ const now = Date.now();
2394
+ if (!options.service && this.actionCatalogCache && this.actionCatalogCache.expiresAt > now) {
2395
+ return this.actionCatalogCache.entries;
2396
+ }
2397
+ const res = await this.request({
2398
+ service: "auth",
2399
+ method: "GET",
2400
+ path: "/api/actions/catalog",
2401
+ ...options.service ? { query: { service: options.service } } : {}
2402
+ });
2403
+ const raw = res.data?.actions ?? res.actions;
2404
+ if (!Array.isArray(raw)) {
2405
+ throw new TopoloSdkError(
2406
+ "action_catalog_unavailable",
2407
+ "TopoloAuth did not return an action catalog for the current credential."
2408
+ );
2409
+ }
2410
+ const entries = raw.map((item) => normalizeActionCatalogEntry(item)).filter((entry) => Boolean(entry));
2411
+ if (!options.service) {
2412
+ this.actionCatalogCache = {
2413
+ entries,
2414
+ expiresAt: now + CATALOG_CACHE_TTL_MS
2415
+ };
2416
+ }
2417
+ return entries;
2418
+ }
2419
+ async getAction(action) {
2420
+ const needle = normalizeActionLookupKey(action);
2421
+ const actions = await this.listActions();
2422
+ const match = actions.find((entry) => actionEntryMatches(entry, needle));
2423
+ if (!match) {
2424
+ throw new TopoloSdkError(
2425
+ "unknown_action",
2426
+ `Unknown Topolo action "${action}". Run action discovery first and pass an action id, name, or tool name.`
2427
+ );
2428
+ }
2429
+ return match;
2430
+ }
2431
+ async callAction(action, input = {}, options = {}) {
2432
+ const entry = await this.getAction(action);
2433
+ const mapped = mapActionInput(entry, input);
2434
+ const service = entry.serviceSlug || entry.serviceId;
2435
+ return this.request({
2436
+ service,
2437
+ method: entry.method,
2438
+ path: mapped.path,
2439
+ ...mapped.query ? { query: mapped.query } : {},
2440
+ ...mapped.body !== void 0 ? { body: mapped.body } : {},
2441
+ confirm: options.confirm === true
2442
+ });
2443
+ }
2386
2444
  /**
2387
2445
  * Bundled generated registry for tooling that audits platform packaging, not
2388
2446
  * for deciding what the current credential may use.
@@ -2492,6 +2550,98 @@ function isCallableCatalogEntry(entry) {
2492
2550
  entry.baseUrl && catalogEntryLookupKeys(entry).some((key) => Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, key))
2493
2551
  );
2494
2552
  }
2553
+ function normalizeActionCatalogEntry(item) {
2554
+ if (!item || typeof item !== "object" || Array.isArray(item)) return null;
2555
+ const a = item;
2556
+ const actionId = pickActionString(a.action_id) ?? pickActionString(a.actionId);
2557
+ const name = pickActionString(a.name);
2558
+ const serviceId = pickActionString(a.service_id) ?? pickActionString(a.serviceId);
2559
+ const method = pickActionString(a.method)?.toUpperCase();
2560
+ const path = pickActionString(a.path);
2561
+ if (!actionId || !name || !serviceId || !method || !path || !isHttpMethod(method)) {
2562
+ return null;
2563
+ }
2564
+ return {
2565
+ actionId,
2566
+ name,
2567
+ toolName: pickActionString(a.tool_name) ?? pickActionString(a.toolName) ?? null,
2568
+ title: pickActionString(a.title) ?? name,
2569
+ description: pickActionString(a.description) ?? "",
2570
+ serviceId,
2571
+ serviceSlug: pickActionString(a.service_slug) ?? pickActionString(a.serviceSlug) ?? null,
2572
+ serviceName: pickActionString(a.service_name) ?? pickActionString(a.serviceName) ?? null,
2573
+ method,
2574
+ path,
2575
+ permissionName: pickActionString(a.permission_name) ?? pickActionString(a.permissionName) ?? "",
2576
+ requiredPermission: pickActionString(a.required_permission) ?? pickActionString(a.requiredPermission) ?? "",
2577
+ inputSchema: plainObject(a.input_schema ?? a.inputSchema),
2578
+ outputSchema: plainObject(a.output_schema ?? a.outputSchema),
2579
+ readOnly: Boolean(a.read_only ?? a.readOnly),
2580
+ destructive: Boolean(a.destructive),
2581
+ requiresConfirmation: Boolean(a.requires_confirmation ?? a.requiresConfirmation),
2582
+ status: pickActionString(a.status),
2583
+ sourceRevision: pickActionString(a.source_revision) ?? pickActionString(a.sourceRevision)
2584
+ };
2585
+ }
2586
+ function isHttpMethod(value) {
2587
+ return value === "GET" || value === "POST" || value === "PUT" || value === "PATCH" || value === "DELETE";
2588
+ }
2589
+ function pickActionString(value) {
2590
+ return typeof value === "string" && value.trim() ? value.trim() : null;
2591
+ }
2592
+ function plainObject(value) {
2593
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
2594
+ }
2595
+ function normalizeActionLookupKey(value) {
2596
+ return String(value || "").trim().toLowerCase();
2597
+ }
2598
+ function actionEntryMatches(entry, needle) {
2599
+ if (!needle) return false;
2600
+ return [entry.actionId, entry.name, entry.toolName].filter((value) => typeof value === "string" && value.length > 0).some((value) => value.toLowerCase() === needle);
2601
+ }
2602
+ function mapActionInput(action, input) {
2603
+ const remaining = { ...input || {} };
2604
+ const path = action.path.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => {
2605
+ const value = remaining[key];
2606
+ if (value === void 0 || value === null || value === "") {
2607
+ throw new TopoloSdkError(
2608
+ "invalid_action_input",
2609
+ `Action "${action.name}" requires path parameter "${key}".`
2610
+ );
2611
+ }
2612
+ delete remaining[key];
2613
+ return encodeURIComponent(String(value));
2614
+ });
2615
+ const explicitQuery = plainObject(remaining.query);
2616
+ delete remaining.query;
2617
+ const explicitBody = Object.prototype.hasOwnProperty.call(remaining, "body") ? remaining.body : void 0;
2618
+ delete remaining.body;
2619
+ if (action.method === "GET") {
2620
+ return {
2621
+ path,
2622
+ query: objectToQuery({ ...remaining, ...explicitQuery })
2623
+ };
2624
+ }
2625
+ const query = objectToQuery(explicitQuery);
2626
+ const body = explicitBody !== void 0 ? explicitBody : remaining;
2627
+ return {
2628
+ path,
2629
+ ...query ? { query } : {},
2630
+ body
2631
+ };
2632
+ }
2633
+ function objectToQuery(value) {
2634
+ const query = {};
2635
+ for (const [key, raw] of Object.entries(value)) {
2636
+ if (raw === void 0 || raw === null) continue;
2637
+ if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
2638
+ query[key] = raw;
2639
+ } else {
2640
+ query[key] = JSON.stringify(raw);
2641
+ }
2642
+ }
2643
+ return Object.keys(query).length > 0 ? query : void 0;
2644
+ }
2495
2645
  function describeError(body, fallback) {
2496
2646
  if (body && typeof body === "object") {
2497
2647
  const maybe = body;
package/dist/index.d.cts CHANGED
@@ -110,6 +110,27 @@ interface TopoloServiceCatalogEntry {
110
110
  launchable: boolean;
111
111
  permissions: string[];
112
112
  }
113
+ interface TopoloActionCatalogEntry {
114
+ actionId: string;
115
+ name: string;
116
+ toolName: string | null;
117
+ title: string;
118
+ description: string;
119
+ serviceId: string;
120
+ serviceSlug: string | null;
121
+ serviceName: string | null;
122
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
123
+ path: string;
124
+ permissionName: string;
125
+ requiredPermission: string;
126
+ inputSchema: Record<string, unknown>;
127
+ outputSchema: Record<string, unknown>;
128
+ readOnly: boolean;
129
+ destructive: boolean;
130
+ requiresConfirmation: boolean;
131
+ status: string | null;
132
+ sourceRevision: string | null;
133
+ }
113
134
  declare class TopoloClient {
114
135
  private readonly credential;
115
136
  private readonly agent;
@@ -120,6 +141,7 @@ declare class TopoloClient {
120
141
  private readonly fetchImpl;
121
142
  private readonly debug?;
122
143
  private serviceCatalogCache;
144
+ private actionCatalogCache;
123
145
  /**
124
146
  * Process-wide cache for slug → service-id lookups. Keyed by
125
147
  * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
@@ -166,6 +188,18 @@ declare class TopoloClient {
166
188
  * organization and effective permissions.
167
189
  */
168
190
  listServices(): Promise<TopoloServiceCatalogEntry[]>;
191
+ /**
192
+ * List callable actions from the live Auth catalog. The response is scoped to
193
+ * the caller's organization and effective permissions; no bundled/static app
194
+ * action list is used as an authorization source.
195
+ */
196
+ listActions(options?: {
197
+ service?: string;
198
+ }): Promise<TopoloActionCatalogEntry[]>;
199
+ getAction(action: string): Promise<TopoloActionCatalogEntry>;
200
+ callAction<T = unknown>(action: string, input?: Record<string, unknown>, options?: {
201
+ confirm?: boolean;
202
+ }): Promise<T>;
169
203
  /**
170
204
  * Bundled generated registry for tooling that audits platform packaging, not
171
205
  * for deciding what the current credential may use.
@@ -1536,7 +1570,7 @@ declare function auditAllApplicationRequirements(applicationIds?: readonly Appli
1536
1570
  * `scripts/generate-services.mjs`; edit the JSON, not the generated file.
1537
1571
  *
1538
1572
  * Any entry can be overridden per-call via `TopoloClient` options, or globally
1539
- * via env vars like `TOPOLO_SERVICE_URL_CRM` or `TOPOLO_SERVICE_URL_WEB_API`
1573
+ * via env vars like `TOPOLO_SERVICE_URL_MAIL` or `TOPOLO_SERVICE_URL_WEB_API`
1540
1574
  * (dots in service IDs become underscores in env vars).
1541
1575
  *
1542
1576
  * IMPORTANT: The SDK NEVER accepts `orgId` as a parameter. Every request is
@@ -2051,4 +2085,4 @@ declare function createTopolo(options: TopoloClientOptions): {
2051
2085
  };
2052
2086
  type Topolo = ReturnType<typeof createTopolo>;
2053
2087
 
2054
- export { APPLICATIONS, APPLICATION_REQUIREMENTS, APPLICATION_REQUIREMENTS_VERSION, type AgentIdentity, type ApplicationCatalogEntry, type ApplicationDeployTarget, type ApplicationId, type ApplicationRequirement, type ApplicationRequirementAudit, type ApplicationRequirementFinding, type ApplicationRequirementMigrationItem, type ApplicationRequirementScope, type ApplicationRequirementScore, type ApplicationRequirementStatus, type ApplicationRequirementsAuditReport, type CredentialIntrospection, type CrmContactSummary, CrmModule, DEFAULT_SERVICE_URLS, type DeviceAuthorizationResponse, IdentityModule, type ListContactsOptions, type ListContactsResult, type OAuthHelperOptions, type P2PActionDetail, type P2PActionRequestInput, type P2PActionRequestResult, type P2PActionRow, type P2PActionRun, type P2PCapability, type P2PConnection, type P2PHold, type P2PLedgerEntry, P2PModule, type P2PPolicy, type P2PPricingModel, type P2PSettlementBatch, type P2PSettlementGroup, type PublishCapabilityInput, type RequestOptions, type ServiceId, TOPOLO_WIDGET_ENDPOINT, TOPOLO_WIDGET_REFRESH_INTERVAL_MS, type TokenResponse, type Topolo, TopoloAuthError, type TopoloChartWidget, TopoloClient, type TopoloClientOptions, type TopoloCredential, type TopoloDebugEvent, TopoloHttpError, type TopoloNotificationsWidget, TopoloOAuth, TopoloPermissionError, type TopoloQuickActionsWidget, type TopoloRecentActivityWidget, TopoloSdkError, type TopoloServiceCatalogEntry, type TopoloStatsWidget, type TopoloWidget, type TopoloWidgetActionItem, type TopoloWidgetActivityItem, type TopoloWidgetApiResponse, type TopoloWidgetBase, type TopoloWidgetConfig, type TopoloWidgetDraft, type TopoloWidgetMetric, type TopoloWidgetNotificationItem, type TopoloWidgetResponseOptions, type TopoloWidgetSnapshotMetadata, type TopoloWidgetSnapshotMetric, type TopoloWidgetType, type TopoloWidgetValidationResult, applicationRequirementScopes, auditAllApplicationRequirements, auditApplicationRequirements, createTopolo, createTopoloWidgetError, createTopoloWidgetResponse, defineTopoloChartWidget, defineTopoloNotificationsWidget, defineTopoloQuickActionsWidget, defineTopoloRecentActivityWidget, defineTopoloStatsWidget, requirementsForApplication, resolveServiceUrl, validateTopoloWidgetResponse };
2088
+ export { APPLICATIONS, APPLICATION_REQUIREMENTS, APPLICATION_REQUIREMENTS_VERSION, type AgentIdentity, type ApplicationCatalogEntry, type ApplicationDeployTarget, type ApplicationId, type ApplicationRequirement, type ApplicationRequirementAudit, type ApplicationRequirementFinding, type ApplicationRequirementMigrationItem, type ApplicationRequirementScope, type ApplicationRequirementScore, type ApplicationRequirementStatus, type ApplicationRequirementsAuditReport, type CredentialIntrospection, type CrmContactSummary, CrmModule, DEFAULT_SERVICE_URLS, type DeviceAuthorizationResponse, IdentityModule, type ListContactsOptions, type ListContactsResult, type OAuthHelperOptions, type P2PActionDetail, type P2PActionRequestInput, type P2PActionRequestResult, type P2PActionRow, type P2PActionRun, type P2PCapability, type P2PConnection, type P2PHold, type P2PLedgerEntry, P2PModule, type P2PPolicy, type P2PPricingModel, type P2PSettlementBatch, type P2PSettlementGroup, type PublishCapabilityInput, type RequestOptions, type ServiceId, TOPOLO_WIDGET_ENDPOINT, TOPOLO_WIDGET_REFRESH_INTERVAL_MS, type TokenResponse, type Topolo, type TopoloActionCatalogEntry, TopoloAuthError, type TopoloChartWidget, TopoloClient, type TopoloClientOptions, type TopoloCredential, type TopoloDebugEvent, TopoloHttpError, type TopoloNotificationsWidget, TopoloOAuth, TopoloPermissionError, type TopoloQuickActionsWidget, type TopoloRecentActivityWidget, TopoloSdkError, type TopoloServiceCatalogEntry, type TopoloStatsWidget, type TopoloWidget, type TopoloWidgetActionItem, type TopoloWidgetActivityItem, type TopoloWidgetApiResponse, type TopoloWidgetBase, type TopoloWidgetConfig, type TopoloWidgetDraft, type TopoloWidgetMetric, type TopoloWidgetNotificationItem, type TopoloWidgetResponseOptions, type TopoloWidgetSnapshotMetadata, type TopoloWidgetSnapshotMetric, type TopoloWidgetType, type TopoloWidgetValidationResult, applicationRequirementScopes, auditAllApplicationRequirements, auditApplicationRequirements, createTopolo, createTopoloWidgetError, createTopoloWidgetResponse, defineTopoloChartWidget, defineTopoloNotificationsWidget, defineTopoloQuickActionsWidget, defineTopoloRecentActivityWidget, defineTopoloStatsWidget, requirementsForApplication, resolveServiceUrl, validateTopoloWidgetResponse };
package/dist/index.d.ts CHANGED
@@ -110,6 +110,27 @@ interface TopoloServiceCatalogEntry {
110
110
  launchable: boolean;
111
111
  permissions: string[];
112
112
  }
113
+ interface TopoloActionCatalogEntry {
114
+ actionId: string;
115
+ name: string;
116
+ toolName: string | null;
117
+ title: string;
118
+ description: string;
119
+ serviceId: string;
120
+ serviceSlug: string | null;
121
+ serviceName: string | null;
122
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
123
+ path: string;
124
+ permissionName: string;
125
+ requiredPermission: string;
126
+ inputSchema: Record<string, unknown>;
127
+ outputSchema: Record<string, unknown>;
128
+ readOnly: boolean;
129
+ destructive: boolean;
130
+ requiresConfirmation: boolean;
131
+ status: string | null;
132
+ sourceRevision: string | null;
133
+ }
113
134
  declare class TopoloClient {
114
135
  private readonly credential;
115
136
  private readonly agent;
@@ -120,6 +141,7 @@ declare class TopoloClient {
120
141
  private readonly fetchImpl;
121
142
  private readonly debug?;
122
143
  private serviceCatalogCache;
144
+ private actionCatalogCache;
123
145
  /**
124
146
  * Process-wide cache for slug → service-id lookups. Keyed by
125
147
  * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
@@ -166,6 +188,18 @@ declare class TopoloClient {
166
188
  * organization and effective permissions.
167
189
  */
168
190
  listServices(): Promise<TopoloServiceCatalogEntry[]>;
191
+ /**
192
+ * List callable actions from the live Auth catalog. The response is scoped to
193
+ * the caller's organization and effective permissions; no bundled/static app
194
+ * action list is used as an authorization source.
195
+ */
196
+ listActions(options?: {
197
+ service?: string;
198
+ }): Promise<TopoloActionCatalogEntry[]>;
199
+ getAction(action: string): Promise<TopoloActionCatalogEntry>;
200
+ callAction<T = unknown>(action: string, input?: Record<string, unknown>, options?: {
201
+ confirm?: boolean;
202
+ }): Promise<T>;
169
203
  /**
170
204
  * Bundled generated registry for tooling that audits platform packaging, not
171
205
  * for deciding what the current credential may use.
@@ -1536,7 +1570,7 @@ declare function auditAllApplicationRequirements(applicationIds?: readonly Appli
1536
1570
  * `scripts/generate-services.mjs`; edit the JSON, not the generated file.
1537
1571
  *
1538
1572
  * Any entry can be overridden per-call via `TopoloClient` options, or globally
1539
- * via env vars like `TOPOLO_SERVICE_URL_CRM` or `TOPOLO_SERVICE_URL_WEB_API`
1573
+ * via env vars like `TOPOLO_SERVICE_URL_MAIL` or `TOPOLO_SERVICE_URL_WEB_API`
1540
1574
  * (dots in service IDs become underscores in env vars).
1541
1575
  *
1542
1576
  * IMPORTANT: The SDK NEVER accepts `orgId` as a parameter. Every request is
@@ -2051,4 +2085,4 @@ declare function createTopolo(options: TopoloClientOptions): {
2051
2085
  };
2052
2086
  type Topolo = ReturnType<typeof createTopolo>;
2053
2087
 
2054
- export { APPLICATIONS, APPLICATION_REQUIREMENTS, APPLICATION_REQUIREMENTS_VERSION, type AgentIdentity, type ApplicationCatalogEntry, type ApplicationDeployTarget, type ApplicationId, type ApplicationRequirement, type ApplicationRequirementAudit, type ApplicationRequirementFinding, type ApplicationRequirementMigrationItem, type ApplicationRequirementScope, type ApplicationRequirementScore, type ApplicationRequirementStatus, type ApplicationRequirementsAuditReport, type CredentialIntrospection, type CrmContactSummary, CrmModule, DEFAULT_SERVICE_URLS, type DeviceAuthorizationResponse, IdentityModule, type ListContactsOptions, type ListContactsResult, type OAuthHelperOptions, type P2PActionDetail, type P2PActionRequestInput, type P2PActionRequestResult, type P2PActionRow, type P2PActionRun, type P2PCapability, type P2PConnection, type P2PHold, type P2PLedgerEntry, P2PModule, type P2PPolicy, type P2PPricingModel, type P2PSettlementBatch, type P2PSettlementGroup, type PublishCapabilityInput, type RequestOptions, type ServiceId, TOPOLO_WIDGET_ENDPOINT, TOPOLO_WIDGET_REFRESH_INTERVAL_MS, type TokenResponse, type Topolo, TopoloAuthError, type TopoloChartWidget, TopoloClient, type TopoloClientOptions, type TopoloCredential, type TopoloDebugEvent, TopoloHttpError, type TopoloNotificationsWidget, TopoloOAuth, TopoloPermissionError, type TopoloQuickActionsWidget, type TopoloRecentActivityWidget, TopoloSdkError, type TopoloServiceCatalogEntry, type TopoloStatsWidget, type TopoloWidget, type TopoloWidgetActionItem, type TopoloWidgetActivityItem, type TopoloWidgetApiResponse, type TopoloWidgetBase, type TopoloWidgetConfig, type TopoloWidgetDraft, type TopoloWidgetMetric, type TopoloWidgetNotificationItem, type TopoloWidgetResponseOptions, type TopoloWidgetSnapshotMetadata, type TopoloWidgetSnapshotMetric, type TopoloWidgetType, type TopoloWidgetValidationResult, applicationRequirementScopes, auditAllApplicationRequirements, auditApplicationRequirements, createTopolo, createTopoloWidgetError, createTopoloWidgetResponse, defineTopoloChartWidget, defineTopoloNotificationsWidget, defineTopoloQuickActionsWidget, defineTopoloRecentActivityWidget, defineTopoloStatsWidget, requirementsForApplication, resolveServiceUrl, validateTopoloWidgetResponse };
2088
+ export { APPLICATIONS, APPLICATION_REQUIREMENTS, APPLICATION_REQUIREMENTS_VERSION, type AgentIdentity, type ApplicationCatalogEntry, type ApplicationDeployTarget, type ApplicationId, type ApplicationRequirement, type ApplicationRequirementAudit, type ApplicationRequirementFinding, type ApplicationRequirementMigrationItem, type ApplicationRequirementScope, type ApplicationRequirementScore, type ApplicationRequirementStatus, type ApplicationRequirementsAuditReport, type CredentialIntrospection, type CrmContactSummary, CrmModule, DEFAULT_SERVICE_URLS, type DeviceAuthorizationResponse, IdentityModule, type ListContactsOptions, type ListContactsResult, type OAuthHelperOptions, type P2PActionDetail, type P2PActionRequestInput, type P2PActionRequestResult, type P2PActionRow, type P2PActionRun, type P2PCapability, type P2PConnection, type P2PHold, type P2PLedgerEntry, P2PModule, type P2PPolicy, type P2PPricingModel, type P2PSettlementBatch, type P2PSettlementGroup, type PublishCapabilityInput, type RequestOptions, type ServiceId, TOPOLO_WIDGET_ENDPOINT, TOPOLO_WIDGET_REFRESH_INTERVAL_MS, type TokenResponse, type Topolo, type TopoloActionCatalogEntry, TopoloAuthError, type TopoloChartWidget, TopoloClient, type TopoloClientOptions, type TopoloCredential, type TopoloDebugEvent, TopoloHttpError, type TopoloNotificationsWidget, TopoloOAuth, TopoloPermissionError, type TopoloQuickActionsWidget, type TopoloRecentActivityWidget, TopoloSdkError, type TopoloServiceCatalogEntry, type TopoloStatsWidget, type TopoloWidget, type TopoloWidgetActionItem, type TopoloWidgetActivityItem, type TopoloWidgetApiResponse, type TopoloWidgetBase, type TopoloWidgetConfig, type TopoloWidgetDraft, type TopoloWidgetMetric, type TopoloWidgetNotificationItem, type TopoloWidgetResponseOptions, type TopoloWidgetSnapshotMetadata, type TopoloWidgetSnapshotMetric, type TopoloWidgetType, type TopoloWidgetValidationResult, applicationRequirementScopes, auditAllApplicationRequirements, auditApplicationRequirements, createTopolo, createTopoloWidgetError, createTopoloWidgetResponse, defineTopoloChartWidget, defineTopoloNotificationsWidget, defineTopoloQuickActionsWidget, defineTopoloRecentActivityWidget, defineTopoloStatsWidget, requirementsForApplication, resolveServiceUrl, validateTopoloWidgetResponse };
package/dist/index.js CHANGED
@@ -2069,6 +2069,7 @@ var TopoloClient = class _TopoloClient {
2069
2069
  fetchImpl;
2070
2070
  debug;
2071
2071
  serviceCatalogCache = null;
2072
+ actionCatalogCache = null;
2072
2073
  /**
2073
2074
  * Process-wide cache for slug → service-id lookups. Keyed by
2074
2075
  * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
@@ -2329,6 +2330,63 @@ var TopoloClient = class _TopoloClient {
2329
2330
  };
2330
2331
  return entries;
2331
2332
  }
2333
+ /**
2334
+ * List callable actions from the live Auth catalog. The response is scoped to
2335
+ * the caller's organization and effective permissions; no bundled/static app
2336
+ * action list is used as an authorization source.
2337
+ */
2338
+ async listActions(options = {}) {
2339
+ const now = Date.now();
2340
+ if (!options.service && this.actionCatalogCache && this.actionCatalogCache.expiresAt > now) {
2341
+ return this.actionCatalogCache.entries;
2342
+ }
2343
+ const res = await this.request({
2344
+ service: "auth",
2345
+ method: "GET",
2346
+ path: "/api/actions/catalog",
2347
+ ...options.service ? { query: { service: options.service } } : {}
2348
+ });
2349
+ const raw = res.data?.actions ?? res.actions;
2350
+ if (!Array.isArray(raw)) {
2351
+ throw new TopoloSdkError(
2352
+ "action_catalog_unavailable",
2353
+ "TopoloAuth did not return an action catalog for the current credential."
2354
+ );
2355
+ }
2356
+ const entries = raw.map((item) => normalizeActionCatalogEntry(item)).filter((entry) => Boolean(entry));
2357
+ if (!options.service) {
2358
+ this.actionCatalogCache = {
2359
+ entries,
2360
+ expiresAt: now + CATALOG_CACHE_TTL_MS
2361
+ };
2362
+ }
2363
+ return entries;
2364
+ }
2365
+ async getAction(action) {
2366
+ const needle = normalizeActionLookupKey(action);
2367
+ const actions = await this.listActions();
2368
+ const match = actions.find((entry) => actionEntryMatches(entry, needle));
2369
+ if (!match) {
2370
+ throw new TopoloSdkError(
2371
+ "unknown_action",
2372
+ `Unknown Topolo action "${action}". Run action discovery first and pass an action id, name, or tool name.`
2373
+ );
2374
+ }
2375
+ return match;
2376
+ }
2377
+ async callAction(action, input = {}, options = {}) {
2378
+ const entry = await this.getAction(action);
2379
+ const mapped = mapActionInput(entry, input);
2380
+ const service = entry.serviceSlug || entry.serviceId;
2381
+ return this.request({
2382
+ service,
2383
+ method: entry.method,
2384
+ path: mapped.path,
2385
+ ...mapped.query ? { query: mapped.query } : {},
2386
+ ...mapped.body !== void 0 ? { body: mapped.body } : {},
2387
+ confirm: options.confirm === true
2388
+ });
2389
+ }
2332
2390
  /**
2333
2391
  * Bundled generated registry for tooling that audits platform packaging, not
2334
2392
  * for deciding what the current credential may use.
@@ -2438,6 +2496,98 @@ function isCallableCatalogEntry(entry) {
2438
2496
  entry.baseUrl && catalogEntryLookupKeys(entry).some((key) => Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, key))
2439
2497
  );
2440
2498
  }
2499
+ function normalizeActionCatalogEntry(item) {
2500
+ if (!item || typeof item !== "object" || Array.isArray(item)) return null;
2501
+ const a = item;
2502
+ const actionId = pickActionString(a.action_id) ?? pickActionString(a.actionId);
2503
+ const name = pickActionString(a.name);
2504
+ const serviceId = pickActionString(a.service_id) ?? pickActionString(a.serviceId);
2505
+ const method = pickActionString(a.method)?.toUpperCase();
2506
+ const path = pickActionString(a.path);
2507
+ if (!actionId || !name || !serviceId || !method || !path || !isHttpMethod(method)) {
2508
+ return null;
2509
+ }
2510
+ return {
2511
+ actionId,
2512
+ name,
2513
+ toolName: pickActionString(a.tool_name) ?? pickActionString(a.toolName) ?? null,
2514
+ title: pickActionString(a.title) ?? name,
2515
+ description: pickActionString(a.description) ?? "",
2516
+ serviceId,
2517
+ serviceSlug: pickActionString(a.service_slug) ?? pickActionString(a.serviceSlug) ?? null,
2518
+ serviceName: pickActionString(a.service_name) ?? pickActionString(a.serviceName) ?? null,
2519
+ method,
2520
+ path,
2521
+ permissionName: pickActionString(a.permission_name) ?? pickActionString(a.permissionName) ?? "",
2522
+ requiredPermission: pickActionString(a.required_permission) ?? pickActionString(a.requiredPermission) ?? "",
2523
+ inputSchema: plainObject(a.input_schema ?? a.inputSchema),
2524
+ outputSchema: plainObject(a.output_schema ?? a.outputSchema),
2525
+ readOnly: Boolean(a.read_only ?? a.readOnly),
2526
+ destructive: Boolean(a.destructive),
2527
+ requiresConfirmation: Boolean(a.requires_confirmation ?? a.requiresConfirmation),
2528
+ status: pickActionString(a.status),
2529
+ sourceRevision: pickActionString(a.source_revision) ?? pickActionString(a.sourceRevision)
2530
+ };
2531
+ }
2532
+ function isHttpMethod(value) {
2533
+ return value === "GET" || value === "POST" || value === "PUT" || value === "PATCH" || value === "DELETE";
2534
+ }
2535
+ function pickActionString(value) {
2536
+ return typeof value === "string" && value.trim() ? value.trim() : null;
2537
+ }
2538
+ function plainObject(value) {
2539
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
2540
+ }
2541
+ function normalizeActionLookupKey(value) {
2542
+ return String(value || "").trim().toLowerCase();
2543
+ }
2544
+ function actionEntryMatches(entry, needle) {
2545
+ if (!needle) return false;
2546
+ return [entry.actionId, entry.name, entry.toolName].filter((value) => typeof value === "string" && value.length > 0).some((value) => value.toLowerCase() === needle);
2547
+ }
2548
+ function mapActionInput(action, input) {
2549
+ const remaining = { ...input || {} };
2550
+ const path = action.path.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => {
2551
+ const value = remaining[key];
2552
+ if (value === void 0 || value === null || value === "") {
2553
+ throw new TopoloSdkError(
2554
+ "invalid_action_input",
2555
+ `Action "${action.name}" requires path parameter "${key}".`
2556
+ );
2557
+ }
2558
+ delete remaining[key];
2559
+ return encodeURIComponent(String(value));
2560
+ });
2561
+ const explicitQuery = plainObject(remaining.query);
2562
+ delete remaining.query;
2563
+ const explicitBody = Object.prototype.hasOwnProperty.call(remaining, "body") ? remaining.body : void 0;
2564
+ delete remaining.body;
2565
+ if (action.method === "GET") {
2566
+ return {
2567
+ path,
2568
+ query: objectToQuery({ ...remaining, ...explicitQuery })
2569
+ };
2570
+ }
2571
+ const query = objectToQuery(explicitQuery);
2572
+ const body = explicitBody !== void 0 ? explicitBody : remaining;
2573
+ return {
2574
+ path,
2575
+ ...query ? { query } : {},
2576
+ body
2577
+ };
2578
+ }
2579
+ function objectToQuery(value) {
2580
+ const query = {};
2581
+ for (const [key, raw] of Object.entries(value)) {
2582
+ if (raw === void 0 || raw === null) continue;
2583
+ if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
2584
+ query[key] = raw;
2585
+ } else {
2586
+ query[key] = JSON.stringify(raw);
2587
+ }
2588
+ }
2589
+ return Object.keys(query).length > 0 ? query : void 0;
2590
+ }
2441
2591
  function describeError(body, fallback) {
2442
2592
  if (body && typeof body === "object") {
2443
2593
  const maybe = body;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/sdk",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Typed client SDK for the Topolo platform. Used by TopoloCli, TopoloMCP, and third-party agents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,18 +17,83 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown; contentTy
17
17
 
18
18
  const AGENT = { clientName: 'topolo-test', clientVersion: '0.0.0' };
19
19
  const SERVICE_LOOKUP = { body: { success: true, data: { service_id: 'service_test' } } };
20
- const CRM_CATALOG = {
20
+ const MAIL_CATALOG = {
21
21
  body: {
22
22
  data: {
23
23
  services: [
24
24
  {
25
- service_id: 'srv_crm',
26
- app_slug: 'topolo-crm',
27
- name: 'Topolo CRM',
28
- base_url: 'https://crm-api.topolo.app',
25
+ service_id: 'srv_mail',
26
+ app_slug: 'topolo-mail',
27
+ name: 'Topolo Mail',
28
+ base_url: 'https://mail-api.topolo.app',
29
29
  surface_kind: 'api',
30
30
  status: 'active',
31
- granted_permissions: ['srv_crm.contacts:read'],
31
+ granted_permissions: ['srv_mail.messages:read'],
32
+ },
33
+ ],
34
+ },
35
+ },
36
+ };
37
+ const ACTION_CATALOG = {
38
+ body: {
39
+ data: {
40
+ actions: [
41
+ {
42
+ action_id: 'srv_mail.messages.list',
43
+ name: 'messages.list',
44
+ tool_name: 'topolo_mail_messages_list',
45
+ title: 'List messages',
46
+ description: 'List Mail messages',
47
+ service_id: 'srv_mail',
48
+ service_slug: 'topolo-mail',
49
+ service_name: 'Topolo Mail',
50
+ method: 'GET',
51
+ path: '/api/mail/mailboxes/{mailboxId}/messages',
52
+ permission_name: 'messages:read',
53
+ required_permission: 'srv_mail.messages:read',
54
+ input_schema: { type: 'object' },
55
+ output_schema: {},
56
+ read_only: true,
57
+ destructive: false,
58
+ requires_confirmation: false,
59
+ },
60
+ {
61
+ action_id: 'srv_mail.messages.get',
62
+ name: 'messages.get',
63
+ tool_name: 'topolo_mail_messages_get',
64
+ title: 'Get message',
65
+ description: 'Get Mail message',
66
+ service_id: 'srv_mail',
67
+ service_slug: 'topolo-mail',
68
+ service_name: 'Topolo Mail',
69
+ method: 'GET',
70
+ path: '/api/mail/mailboxes/{mailboxId}/messages/{messageId}',
71
+ permission_name: 'messages:read',
72
+ required_permission: 'srv_mail.messages:read',
73
+ input_schema: { type: 'object' },
74
+ output_schema: {},
75
+ read_only: true,
76
+ destructive: false,
77
+ requires_confirmation: false,
78
+ },
79
+ {
80
+ action_id: 'srv_mail.messages.send',
81
+ name: 'messages.send',
82
+ tool_name: 'topolo_mail_messages_send',
83
+ title: 'Send message',
84
+ description: 'Send Mail message',
85
+ service_id: 'srv_mail',
86
+ service_slug: 'topolo-mail',
87
+ service_name: 'Topolo Mail',
88
+ method: 'POST',
89
+ path: '/api/mail/mailboxes/{mailboxId}/messages',
90
+ permission_name: 'messages:send',
91
+ required_permission: 'srv_mail.messages:send',
92
+ input_schema: { type: 'object' },
93
+ output_schema: {},
94
+ read_only: false,
95
+ destructive: false,
96
+ requires_confirmation: true,
32
97
  },
33
98
  ],
34
99
  },
@@ -38,7 +103,8 @@ const CRM_CATALOG = {
38
103
  function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
39
104
  return calls.filter((call) =>
40
105
  !call.url.includes('/api/services/by-slug/') &&
41
- !call.url.includes('/api/services/catalog')
106
+ !call.url.includes('/api/services/catalog') &&
107
+ !call.url.includes('/api/actions/catalog')
42
108
  );
43
109
  }
44
110
 
@@ -48,7 +114,7 @@ beforeEach(() => {
48
114
 
49
115
  describe('TopoloClient.request', () => {
50
116
  it('injects Bearer auth + audit headers for access_token credentials', async () => {
51
- const { impl, calls } = stubFetch([CRM_CATALOG, { body: { ok: true } }]);
117
+ const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { ok: true } }]);
52
118
  const client = new TopoloClient({
53
119
  credential: { kind: 'access_token', accessToken: 'tok_abc' },
54
120
  agent: AGENT,
@@ -64,13 +130,13 @@ describe('TopoloClient.request', () => {
64
130
  });
65
131
 
66
132
  it('uses X-Api-Key header for api_key credentials', async () => {
67
- const { impl, calls } = stubFetch([CRM_CATALOG, { body: { ok: true } }]);
133
+ const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { ok: true } }]);
68
134
  const client = new TopoloClient({
69
135
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
70
136
  agent: AGENT,
71
137
  fetch: impl,
72
138
  });
73
- await client.request({ service: 'crm', path: '/api/contacts' });
139
+ await client.request({ service: 'mail', path: '/api/mail/mailboxes/primary/messages' });
74
140
  const headers = new Headers(requestCalls(calls)[0]!.init.headers);
75
141
  expect(headers.get('X-Api-Key')).toBe('dak_xyz');
76
142
  expect(headers.get('Authorization')).toBeNull();
@@ -84,22 +150,22 @@ describe('TopoloClient.request', () => {
84
150
  fetch: impl,
85
151
  });
86
152
  await expect(
87
- client.request({ service: 'crm', method: 'POST', path: '/api/contacts', body: {} }),
153
+ client.request({ service: 'mail', method: 'POST', path: '/api/mail/mailboxes/primary/messages', body: {} }),
88
154
  ).rejects.toBeInstanceOf(TopoloAuthError);
89
155
  });
90
156
 
91
157
  it('allows mutating requests with confirm: true', async () => {
92
- const { impl, calls } = stubFetch([CRM_CATALOG, { body: { id: 'c_1' } }]);
158
+ const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { id: 'm_1' } }]);
93
159
  const client = new TopoloClient({
94
160
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
95
161
  agent: AGENT,
96
162
  fetch: impl,
97
163
  });
98
164
  await client.request({
99
- service: 'crm',
165
+ service: 'mail',
100
166
  method: 'POST',
101
- path: '/api/contacts',
102
- body: { email: 'a@b.co' },
167
+ path: '/api/mail/mailboxes/primary/messages',
168
+ body: { subject: 'Hello' },
103
169
  confirm: true,
104
170
  });
105
171
  expect(requestCalls(calls)[0]!.init.method).toBe('POST');
@@ -142,8 +208,8 @@ describe('TopoloClient.request', () => {
142
208
 
143
209
  it('maps 403 → TopoloPermissionError with required scopes extracted', async () => {
144
210
  const { impl } = stubFetch([
145
- CRM_CATALOG,
146
- { status: 403, body: { error: 'forbidden', required: ['crm.contacts:write'] } },
211
+ MAIL_CATALOG,
212
+ { status: 403, body: { error: 'forbidden', required: ['mail.messages:send'] } },
147
213
  ]);
148
214
  const client = new TopoloClient({
149
215
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
@@ -151,31 +217,31 @@ describe('TopoloClient.request', () => {
151
217
  fetch: impl,
152
218
  });
153
219
  await expect(
154
- client.request({ service: 'crm', method: 'POST', path: '/api/contacts', body: {}, confirm: true }),
220
+ client.request({ service: 'mail', method: 'POST', path: '/api/mail/mailboxes/primary/messages', body: {}, confirm: true }),
155
221
  ).rejects.toBeInstanceOf(TopoloPermissionError);
156
222
  });
157
223
 
158
224
  it('maps non-401/403 failures → TopoloHttpError with status preserved', async () => {
159
- const { impl } = stubFetch([CRM_CATALOG, { status: 500, body: { error: 'boom' } }]);
225
+ const { impl } = stubFetch([MAIL_CATALOG, { status: 500, body: { error: 'boom' } }]);
160
226
  const client = new TopoloClient({
161
227
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
162
228
  agent: AGENT,
163
229
  fetch: impl,
164
230
  });
165
- await expect(client.request({ service: 'crm', path: '/api/contacts' })).rejects.toMatchObject({
231
+ await expect(client.request({ service: 'mail', path: '/api/mail/mailboxes/primary/messages' })).rejects.toMatchObject({
166
232
  name: 'TopoloHttpError',
167
233
  status: 500,
168
234
  });
169
235
  });
170
236
 
171
237
  it('appends query params as search string', async () => {
172
- const { impl, calls } = stubFetch([CRM_CATALOG, { body: { contacts: [] } }]);
238
+ const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { messages: [] } }]);
173
239
  const client = new TopoloClient({
174
240
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
175
241
  agent: AGENT,
176
242
  fetch: impl,
177
243
  });
178
- await client.request({ service: 'crm', path: '/api/contacts', query: { q: 'acme', page: 2 } });
244
+ await client.request({ service: 'mail', path: '/api/mail/mailboxes/primary/messages', query: { q: 'acme', page: 2 } });
179
245
  const u = new URL(requestCalls(calls)[0]!.url);
180
246
  expect(u.searchParams.get('q')).toBe('acme');
181
247
  expect(u.searchParams.get('page')).toBe('2');
@@ -576,5 +642,86 @@ describe('TopoloClient.listServices', () => {
576
642
  });
577
643
  });
578
644
 
645
+ describe('TopoloClient action catalog', () => {
646
+ it('lists scoped actions from Auth', async () => {
647
+ const { impl, calls } = stubFetch([ACTION_CATALOG]);
648
+ const client = new TopoloClient({
649
+ credential: { kind: 'access_token', accessToken: 'tok_abc' },
650
+ agent: AGENT,
651
+ fetch: impl,
652
+ serviceUrls: { auth: 'https://auth.example.com' },
653
+ });
654
+ const actions = await client.listActions();
655
+ expect(actions.map((action) => action.name)).toEqual([
656
+ 'messages.list',
657
+ 'messages.get',
658
+ 'messages.send',
659
+ ]);
660
+ expect(calls[0]!.url).toBe('https://auth.example.com/api/actions/catalog');
661
+ });
662
+
663
+ it('calls GET actions by mapping remaining input to query params', async () => {
664
+ const { impl, calls } = stubFetch([ACTION_CATALOG, MAIL_CATALOG, { body: { messages: [] } }]);
665
+ const client = new TopoloClient({
666
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
667
+ agent: AGENT,
668
+ fetch: impl,
669
+ serviceUrls: { auth: 'https://auth.example.com' },
670
+ });
671
+ await client.callAction('messages.list', { mailboxId: 'primary', q: 'Ada', page: 2 });
672
+ const call = requestCalls(calls)[0]!;
673
+ const url = new URL(call.url);
674
+ expect(url.toString()).toBe('https://mail-api.topolo.app/api/mail/mailboxes/primary/messages?q=Ada&page=2');
675
+ expect(call.init.method).toBe('GET');
676
+ });
677
+
678
+ it('substitutes action path params and removes them from the query', async () => {
679
+ const { impl, calls } = stubFetch([ACTION_CATALOG, MAIL_CATALOG, { body: { id: 'm1' } }]);
680
+ const client = new TopoloClient({
681
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
682
+ agent: AGENT,
683
+ fetch: impl,
684
+ serviceUrls: { auth: 'https://auth.example.com' },
685
+ });
686
+ await client.callAction('topolo_mail_messages_get', {
687
+ mailboxId: 'primary',
688
+ messageId: 'm 1',
689
+ includeDeleted: false,
690
+ });
691
+ const url = new URL(requestCalls(calls)[0]!.url);
692
+ expect(url.pathname).toBe('/api/mail/mailboxes/primary/messages/m%201');
693
+ expect(url.searchParams.get('includeDeleted')).toBe('false');
694
+ expect(url.searchParams.has('mailboxId')).toBe(false);
695
+ expect(url.searchParams.has('messageId')).toBe(false);
696
+ });
697
+
698
+ it('requires confirmation for mutating action calls', async () => {
699
+ const { impl } = stubFetch([ACTION_CATALOG]);
700
+ const client = new TopoloClient({
701
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
702
+ agent: AGENT,
703
+ fetch: impl,
704
+ serviceUrls: { auth: 'https://auth.example.com' },
705
+ });
706
+ await expect(
707
+ client.callAction('messages.send', { mailboxId: 'primary', subject: 'Hello' }),
708
+ ).rejects.toBeInstanceOf(TopoloAuthError);
709
+ });
710
+
711
+ it('sends remaining input as the body when a mutating action is confirmed', async () => {
712
+ const { impl, calls } = stubFetch([ACTION_CATALOG, MAIL_CATALOG, { body: { id: 'm1' } }]);
713
+ const client = new TopoloClient({
714
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
715
+ agent: AGENT,
716
+ fetch: impl,
717
+ serviceUrls: { auth: 'https://auth.example.com' },
718
+ });
719
+ await client.callAction('messages.send', { mailboxId: 'primary', subject: 'Hello' }, { confirm: true });
720
+ const call = requestCalls(calls)[0]!;
721
+ expect(call.init.method).toBe('POST');
722
+ expect(JSON.parse(String(call.init.body))).toEqual({ subject: 'Hello' });
723
+ });
724
+ });
725
+
579
726
  // Silence unused-import warning if the bundler later tree-shakes these
580
727
  void TopoloHttpError;
package/src/client.ts CHANGED
@@ -110,6 +110,28 @@ export interface TopoloServiceCatalogEntry {
110
110
  permissions: string[];
111
111
  }
112
112
 
113
+ export interface TopoloActionCatalogEntry {
114
+ actionId: string;
115
+ name: string;
116
+ toolName: string | null;
117
+ title: string;
118
+ description: string;
119
+ serviceId: string;
120
+ serviceSlug: string | null;
121
+ serviceName: string | null;
122
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
123
+ path: string;
124
+ permissionName: string;
125
+ requiredPermission: string;
126
+ inputSchema: Record<string, unknown>;
127
+ outputSchema: Record<string, unknown>;
128
+ readOnly: boolean;
129
+ destructive: boolean;
130
+ requiresConfirmation: boolean;
131
+ status: string | null;
132
+ sourceRevision: string | null;
133
+ }
134
+
113
135
  export class TopoloClient {
114
136
  private readonly credential: TopoloCredential;
115
137
  private readonly agent: AgentIdentity;
@@ -120,6 +142,7 @@ export class TopoloClient {
120
142
  private readonly fetchImpl: typeof fetch;
121
143
  private readonly debug?: (event: TopoloDebugEvent) => void;
122
144
  private serviceCatalogCache: { entries: TopoloServiceCatalogEntry[]; expiresAt: number } | null = null;
145
+ private actionCatalogCache: { entries: TopoloActionCatalogEntry[]; expiresAt: number } | null = null;
123
146
 
124
147
  /**
125
148
  * Process-wide cache for slug → service-id lookups. Keyed by
@@ -437,6 +460,79 @@ export class TopoloClient {
437
460
  return entries;
438
461
  }
439
462
 
463
+ /**
464
+ * List callable actions from the live Auth catalog. The response is scoped to
465
+ * the caller's organization and effective permissions; no bundled/static app
466
+ * action list is used as an authorization source.
467
+ */
468
+ async listActions(options: { service?: string } = {}): Promise<TopoloActionCatalogEntry[]> {
469
+ const now = Date.now();
470
+ if (!options.service && this.actionCatalogCache && this.actionCatalogCache.expiresAt > now) {
471
+ return this.actionCatalogCache.entries;
472
+ }
473
+
474
+ const res = await this.request<
475
+ | { data?: { actions?: unknown[] } }
476
+ | { actions?: unknown[] }
477
+ >({
478
+ service: 'auth',
479
+ method: 'GET',
480
+ path: '/api/actions/catalog',
481
+ ...(options.service ? { query: { service: options.service } } : {}),
482
+ });
483
+ const raw =
484
+ (res as { data?: { actions?: unknown[] } }).data?.actions ??
485
+ (res as { actions?: unknown[] }).actions;
486
+ if (!Array.isArray(raw)) {
487
+ throw new TopoloSdkError(
488
+ 'action_catalog_unavailable',
489
+ 'TopoloAuth did not return an action catalog for the current credential.',
490
+ );
491
+ }
492
+
493
+ const entries = raw
494
+ .map((item) => normalizeActionCatalogEntry(item))
495
+ .filter((entry): entry is TopoloActionCatalogEntry => Boolean(entry));
496
+ if (!options.service) {
497
+ this.actionCatalogCache = {
498
+ entries,
499
+ expiresAt: now + CATALOG_CACHE_TTL_MS,
500
+ };
501
+ }
502
+ return entries;
503
+ }
504
+
505
+ async getAction(action: string): Promise<TopoloActionCatalogEntry> {
506
+ const needle = normalizeActionLookupKey(action);
507
+ const actions = await this.listActions();
508
+ const match = actions.find((entry) => actionEntryMatches(entry, needle));
509
+ if (!match) {
510
+ throw new TopoloSdkError(
511
+ 'unknown_action',
512
+ `Unknown Topolo action "${action}". Run action discovery first and pass an action id, name, or tool name.`,
513
+ );
514
+ }
515
+ return match;
516
+ }
517
+
518
+ async callAction<T = unknown>(
519
+ action: string,
520
+ input: Record<string, unknown> = {},
521
+ options: { confirm?: boolean } = {},
522
+ ): Promise<T> {
523
+ const entry = await this.getAction(action);
524
+ const mapped = mapActionInput(entry, input);
525
+ const service = entry.serviceSlug || entry.serviceId;
526
+ return this.request<T>({
527
+ service,
528
+ method: entry.method,
529
+ path: mapped.path,
530
+ ...(mapped.query ? { query: mapped.query } : {}),
531
+ ...(mapped.body !== undefined ? { body: mapped.body } : {}),
532
+ confirm: options.confirm === true,
533
+ });
534
+ }
535
+
440
536
  /**
441
537
  * Bundled generated registry for tooling that audits platform packaging, not
442
538
  * for deciding what the current credential may use.
@@ -581,6 +677,119 @@ function isCallableCatalogEntry(entry: TopoloServiceCatalogEntry): boolean {
581
677
  );
582
678
  }
583
679
 
680
+ function normalizeActionCatalogEntry(item: unknown): TopoloActionCatalogEntry | null {
681
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
682
+ const a = item as Record<string, unknown>;
683
+ const actionId = pickActionString(a.action_id) ?? pickActionString(a.actionId);
684
+ const name = pickActionString(a.name);
685
+ const serviceId = pickActionString(a.service_id) ?? pickActionString(a.serviceId);
686
+ const method = pickActionString(a.method)?.toUpperCase();
687
+ const path = pickActionString(a.path);
688
+ if (!actionId || !name || !serviceId || !method || !path || !isHttpMethod(method)) {
689
+ return null;
690
+ }
691
+
692
+ return {
693
+ actionId,
694
+ name,
695
+ toolName: pickActionString(a.tool_name) ?? pickActionString(a.toolName) ?? null,
696
+ title: pickActionString(a.title) ?? name,
697
+ description: pickActionString(a.description) ?? '',
698
+ serviceId,
699
+ serviceSlug: pickActionString(a.service_slug) ?? pickActionString(a.serviceSlug) ?? null,
700
+ serviceName: pickActionString(a.service_name) ?? pickActionString(a.serviceName) ?? null,
701
+ method,
702
+ path,
703
+ permissionName: pickActionString(a.permission_name) ?? pickActionString(a.permissionName) ?? '',
704
+ requiredPermission: pickActionString(a.required_permission) ?? pickActionString(a.requiredPermission) ?? '',
705
+ inputSchema: plainObject(a.input_schema ?? a.inputSchema),
706
+ outputSchema: plainObject(a.output_schema ?? a.outputSchema),
707
+ readOnly: Boolean(a.read_only ?? a.readOnly),
708
+ destructive: Boolean(a.destructive),
709
+ requiresConfirmation: Boolean(a.requires_confirmation ?? a.requiresConfirmation),
710
+ status: pickActionString(a.status),
711
+ sourceRevision: pickActionString(a.source_revision) ?? pickActionString(a.sourceRevision),
712
+ };
713
+ }
714
+
715
+ function isHttpMethod(value: string): value is TopoloActionCatalogEntry['method'] {
716
+ return value === 'GET' || value === 'POST' || value === 'PUT' || value === 'PATCH' || value === 'DELETE';
717
+ }
718
+
719
+ function pickActionString(value: unknown): string | null {
720
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
721
+ }
722
+
723
+ function plainObject(value: unknown): Record<string, unknown> {
724
+ return value && typeof value === 'object' && !Array.isArray(value)
725
+ ? value as Record<string, unknown>
726
+ : {};
727
+ }
728
+
729
+ function normalizeActionLookupKey(value: string): string {
730
+ return String(value || '').trim().toLowerCase();
731
+ }
732
+
733
+ function actionEntryMatches(entry: TopoloActionCatalogEntry, needle: string): boolean {
734
+ if (!needle) return false;
735
+ return [entry.actionId, entry.name, entry.toolName]
736
+ .filter((value): value is string => typeof value === 'string' && value.length > 0)
737
+ .some((value) => value.toLowerCase() === needle);
738
+ }
739
+
740
+ function mapActionInput(
741
+ action: TopoloActionCatalogEntry,
742
+ input: Record<string, unknown>,
743
+ ): { path: string; query?: Record<string, string | number | boolean>; body?: unknown } {
744
+ const remaining: Record<string, unknown> = { ...(input || {}) };
745
+ const path = action.path.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key: string) => {
746
+ const value = remaining[key];
747
+ if (value === undefined || value === null || value === '') {
748
+ throw new TopoloSdkError(
749
+ 'invalid_action_input',
750
+ `Action "${action.name}" requires path parameter "${key}".`,
751
+ );
752
+ }
753
+ delete remaining[key];
754
+ return encodeURIComponent(String(value));
755
+ });
756
+
757
+ const explicitQuery = plainObject(remaining.query);
758
+ delete remaining.query;
759
+ const explicitBody = Object.prototype.hasOwnProperty.call(remaining, 'body')
760
+ ? remaining.body
761
+ : undefined;
762
+ delete remaining.body;
763
+
764
+ if (action.method === 'GET') {
765
+ return {
766
+ path,
767
+ query: objectToQuery({ ...remaining, ...explicitQuery }),
768
+ };
769
+ }
770
+
771
+ const query = objectToQuery(explicitQuery);
772
+ const body = explicitBody !== undefined ? explicitBody : remaining;
773
+ return {
774
+ path,
775
+ ...(query ? { query } : {}),
776
+ body,
777
+ };
778
+ }
779
+
780
+ function objectToQuery(value: Record<string, unknown>): Record<string, string | number | boolean> | undefined {
781
+ const query: Record<string, string | number | boolean> = {};
782
+ for (const [key, raw] of Object.entries(value)) {
783
+ if (raw === undefined || raw === null) continue;
784
+ if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
785
+ query[key] = raw;
786
+ } else {
787
+ query[key] = JSON.stringify(raw);
788
+ }
789
+ }
790
+ return Object.keys(query).length > 0 ? query : undefined;
791
+ }
792
+
584
793
  interface AuthMePayload {
585
794
  credentialType?: 'access_token' | 'api_key';
586
795
  user: RawUser;
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export {
5
5
  type CredentialIntrospection,
6
6
  type TopoloDebugEvent,
7
7
  type TopoloServiceCatalogEntry,
8
+ type TopoloActionCatalogEntry,
8
9
  } from "./client.js";
9
10
  export type { TopoloCredential, AgentIdentity } from "./auth.js";
10
11
  export {
package/src/services.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * `scripts/generate-services.mjs`; edit the JSON, not the generated file.
7
7
  *
8
8
  * Any entry can be overridden per-call via `TopoloClient` options, or globally
9
- * via env vars like `TOPOLO_SERVICE_URL_CRM` or `TOPOLO_SERVICE_URL_WEB_API`
9
+ * via env vars like `TOPOLO_SERVICE_URL_MAIL` or `TOPOLO_SERVICE_URL_WEB_API`
10
10
  * (dots in service IDs become underscores in env vars).
11
11
  *
12
12
  * IMPORTANT: The SDK NEVER accepts `orgId` as a parameter. Every request is