@collage-dam/mcp-server 0.1.0
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/.env.example +56 -0
- package/CHANGELOG.md +90 -0
- package/LICENSE +21 -0
- package/README.md +512 -0
- package/dist/client.d.ts +497 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1162 -0
- package/dist/client.js.map +1 -0
- package/dist/conventions/confirmation.d.ts +89 -0
- package/dist/conventions/confirmation.d.ts.map +1 -0
- package/dist/conventions/confirmation.js +132 -0
- package/dist/conventions/confirmation.js.map +1 -0
- package/dist/conventions/dry-run/batch-executor.d.ts +36 -0
- package/dist/conventions/dry-run/batch-executor.d.ts.map +1 -0
- package/dist/conventions/dry-run/batch-executor.js +89 -0
- package/dist/conventions/dry-run/batch-executor.js.map +1 -0
- package/dist/conventions/dry-run/diff-renderer.d.ts +34 -0
- package/dist/conventions/dry-run/diff-renderer.d.ts.map +1 -0
- package/dist/conventions/dry-run/diff-renderer.js +158 -0
- package/dist/conventions/dry-run/diff-renderer.js.map +1 -0
- package/dist/conventions/dry-run/index.d.ts +13 -0
- package/dist/conventions/dry-run/index.d.ts.map +1 -0
- package/dist/conventions/dry-run/index.js +10 -0
- package/dist/conventions/dry-run/index.js.map +1 -0
- package/dist/conventions/dry-run/mutating-tool.d.ts +64 -0
- package/dist/conventions/dry-run/mutating-tool.d.ts.map +1 -0
- package/dist/conventions/dry-run/mutating-tool.js +88 -0
- package/dist/conventions/dry-run/mutating-tool.js.map +1 -0
- package/dist/conventions/dry-run/summary.d.ts +66 -0
- package/dist/conventions/dry-run/summary.d.ts.map +1 -0
- package/dist/conventions/dry-run/summary.js +185 -0
- package/dist/conventions/dry-run/summary.js.map +1 -0
- package/dist/conventions/dry-run/types.d.ts +597 -0
- package/dist/conventions/dry-run/types.d.ts.map +1 -0
- package/dist/conventions/dry-run/types.js +108 -0
- package/dist/conventions/dry-run/types.js.map +1 -0
- package/dist/conventions/dry-run/with-dry-run.d.ts +66 -0
- package/dist/conventions/dry-run/with-dry-run.d.ts.map +1 -0
- package/dist/conventions/dry-run/with-dry-run.js +219 -0
- package/dist/conventions/dry-run/with-dry-run.js.map +1 -0
- package/dist/conventions/env.d.ts +49 -0
- package/dist/conventions/env.d.ts.map +1 -0
- package/dist/conventions/env.js +84 -0
- package/dist/conventions/env.js.map +1 -0
- package/dist/conventions/errors.d.ts +68 -0
- package/dist/conventions/errors.d.ts.map +1 -0
- package/dist/conventions/errors.js +81 -0
- package/dist/conventions/errors.js.map +1 -0
- package/dist/conventions/logger.d.ts +28 -0
- package/dist/conventions/logger.d.ts.map +1 -0
- package/dist/conventions/logger.js +105 -0
- package/dist/conventions/logger.js.map +1 -0
- package/dist/conventions/pagination.d.ts +37 -0
- package/dist/conventions/pagination.d.ts.map +1 -0
- package/dist/conventions/pagination.js +53 -0
- package/dist/conventions/pagination.js.map +1 -0
- package/dist/conventions/rate-limiter.d.ts +54 -0
- package/dist/conventions/rate-limiter.d.ts.map +1 -0
- package/dist/conventions/rate-limiter.js +143 -0
- package/dist/conventions/rate-limiter.js.map +1 -0
- package/dist/conventions/response-budget.d.ts +66 -0
- package/dist/conventions/response-budget.d.ts.map +1 -0
- package/dist/conventions/response-budget.js +89 -0
- package/dist/conventions/response-budget.js.map +1 -0
- package/dist/conventions/schema-version.d.ts +27 -0
- package/dist/conventions/schema-version.d.ts.map +1 -0
- package/dist/conventions/schema-version.js +29 -0
- package/dist/conventions/schema-version.js.map +1 -0
- package/dist/conventions/state-store-redis.d.ts +32 -0
- package/dist/conventions/state-store-redis.d.ts.map +1 -0
- package/dist/conventions/state-store-redis.js +77 -0
- package/dist/conventions/state-store-redis.js.map +1 -0
- package/dist/conventions/state-store.d.ts +46 -0
- package/dist/conventions/state-store.d.ts.map +1 -0
- package/dist/conventions/state-store.js +105 -0
- package/dist/conventions/state-store.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +421 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts/collection-audit.d.ts +13 -0
- package/dist/prompts/collection-audit.d.ts.map +1 -0
- package/dist/prompts/collection-audit.js +168 -0
- package/dist/prompts/collection-audit.js.map +1 -0
- package/dist/prompts/create-distribution.d.ts +15 -0
- package/dist/prompts/create-distribution.d.ts.map +1 -0
- package/dist/prompts/create-distribution.js +111 -0
- package/dist/prompts/create-distribution.js.map +1 -0
- package/dist/prompts/helpers.d.ts +20 -0
- package/dist/prompts/helpers.d.ts.map +1 -0
- package/dist/prompts/helpers.js +53 -0
- package/dist/prompts/helpers.js.map +1 -0
- package/dist/prompts/library-health-audit.d.ts +13 -0
- package/dist/prompts/library-health-audit.d.ts.map +1 -0
- package/dist/prompts/library-health-audit.js +131 -0
- package/dist/prompts/library-health-audit.js.map +1 -0
- package/dist/prompts/usage-insights.d.ts +13 -0
- package/dist/prompts/usage-insights.d.ts.map +1 -0
- package/dist/prompts/usage-insights.js +98 -0
- package/dist/prompts/usage-insights.js.map +1 -0
- package/dist/prompts/wrap-prompt-as-tool.d.ts +48 -0
- package/dist/prompts/wrap-prompt-as-tool.d.ts.map +1 -0
- package/dist/prompts/wrap-prompt-as-tool.js +61 -0
- package/dist/prompts/wrap-prompt-as-tool.js.map +1 -0
- package/dist/resources/asset-by-id.d.ts +4 -0
- package/dist/resources/asset-by-id.d.ts.map +1 -0
- package/dist/resources/asset-by-id.js +27 -0
- package/dist/resources/asset-by-id.js.map +1 -0
- package/dist/resources/collections.d.ts +5 -0
- package/dist/resources/collections.d.ts.map +1 -0
- package/dist/resources/collections.js +48 -0
- package/dist/resources/collections.js.map +1 -0
- package/dist/resources/custom-fields.d.ts +4 -0
- package/dist/resources/custom-fields.d.ts.map +1 -0
- package/dist/resources/custom-fields.js +30 -0
- package/dist/resources/custom-fields.js.map +1 -0
- package/dist/resources/folders.d.ts +5 -0
- package/dist/resources/folders.d.ts.map +1 -0
- package/dist/resources/folders.js +73 -0
- package/dist/resources/folders.js.map +1 -0
- package/dist/resources/helpers.d.ts +17 -0
- package/dist/resources/helpers.d.ts.map +1 -0
- package/dist/resources/helpers.js +59 -0
- package/dist/resources/helpers.js.map +1 -0
- package/dist/resources/portals.d.ts +5 -0
- package/dist/resources/portals.d.ts.map +1 -0
- package/dist/resources/portals.js +81 -0
- package/dist/resources/portals.js.map +1 -0
- package/dist/resources/recent-and-dashboard.d.ts +5 -0
- package/dist/resources/recent-and-dashboard.d.ts.map +1 -0
- package/dist/resources/recent-and-dashboard.js +42 -0
- package/dist/resources/recent-and-dashboard.js.map +1 -0
- package/dist/tools/asset-selection.d.ts +102 -0
- package/dist/tools/asset-selection.d.ts.map +1 -0
- package/dist/tools/asset-selection.js +133 -0
- package/dist/tools/asset-selection.js.map +1 -0
- package/dist/tools/audit/audit-folder-structure.d.ts +108 -0
- package/dist/tools/audit/audit-folder-structure.d.ts.map +1 -0
- package/dist/tools/audit/audit-folder-structure.js +260 -0
- package/dist/tools/audit/audit-folder-structure.js.map +1 -0
- package/dist/tools/audit/audit-naming-conventions.d.ts +83 -0
- package/dist/tools/audit/audit-naming-conventions.d.ts.map +1 -0
- package/dist/tools/audit/audit-naming-conventions.js +238 -0
- package/dist/tools/audit/audit-naming-conventions.js.map +1 -0
- package/dist/tools/audit/audit-tagging-hygiene.d.ts +77 -0
- package/dist/tools/audit/audit-tagging-hygiene.d.ts.map +1 -0
- package/dist/tools/audit/audit-tagging-hygiene.js +402 -0
- package/dist/tools/audit/audit-tagging-hygiene.js.map +1 -0
- package/dist/tools/audit/detect-duplicates.d.ts +62 -0
- package/dist/tools/audit/detect-duplicates.d.ts.map +1 -0
- package/dist/tools/audit/detect-duplicates.js +0 -0
- package/dist/tools/audit/detect-duplicates.js.map +1 -0
- package/dist/tools/audit/types.d.ts +526 -0
- package/dist/tools/audit/types.d.ts.map +1 -0
- package/dist/tools/audit/types.js +188 -0
- package/dist/tools/audit/types.js.map +1 -0
- package/dist/tools/bulk-move-assets.d.ts +78 -0
- package/dist/tools/bulk-move-assets.d.ts.map +1 -0
- package/dist/tools/bulk-move-assets.js +122 -0
- package/dist/tools/bulk-move-assets.js.map +1 -0
- package/dist/tools/bulk-normalize-filenames.d.ts +62 -0
- package/dist/tools/bulk-normalize-filenames.d.ts.map +1 -0
- package/dist/tools/bulk-normalize-filenames.js +237 -0
- package/dist/tools/bulk-normalize-filenames.js.map +1 -0
- package/dist/tools/bulk-rename-assets.d.ts +79 -0
- package/dist/tools/bulk-rename-assets.d.ts.map +1 -0
- package/dist/tools/bulk-rename-assets.js +139 -0
- package/dist/tools/bulk-rename-assets.js.map +1 -0
- package/dist/tools/bulk-tags.d.ts +107 -0
- package/dist/tools/bulk-tags.d.ts.map +1 -0
- package/dist/tools/bulk-tags.js +220 -0
- package/dist/tools/bulk-tags.js.map +1 -0
- package/dist/tools/client-adapters.d.ts +76 -0
- package/dist/tools/client-adapters.d.ts.map +1 -0
- package/dist/tools/client-adapters.js +648 -0
- package/dist/tools/client-adapters.js.map +1 -0
- package/dist/tools/collection-membership.d.ts +90 -0
- package/dist/tools/collection-membership.d.ts.map +1 -0
- package/dist/tools/collection-membership.js +195 -0
- package/dist/tools/collection-membership.js.map +1 -0
- package/dist/tools/create-collection.d.ts +63 -0
- package/dist/tools/create-collection.d.ts.map +1 -0
- package/dist/tools/create-collection.js +151 -0
- package/dist/tools/create-collection.js.map +1 -0
- package/dist/tools/create-folder.d.ts +46 -0
- package/dist/tools/create-folder.d.ts.map +1 -0
- package/dist/tools/create-folder.js +83 -0
- package/dist/tools/create-folder.js.map +1 -0
- package/dist/tools/create-share-link.d.ts +107 -0
- package/dist/tools/create-share-link.d.ts.map +1 -0
- package/dist/tools/create-share-link.js +239 -0
- package/dist/tools/create-share-link.js.map +1 -0
- package/dist/tools/get-asset-details.d.ts +401 -0
- package/dist/tools/get-asset-details.d.ts.map +1 -0
- package/dist/tools/get-asset-details.js +56 -0
- package/dist/tools/get-asset-details.js.map +1 -0
- package/dist/tools/get-collection.d.ts +126 -0
- package/dist/tools/get-collection.d.ts.map +1 -0
- package/dist/tools/get-collection.js +52 -0
- package/dist/tools/get-collection.js.map +1 -0
- package/dist/tools/get-embed-code.d.ts +195 -0
- package/dist/tools/get-embed-code.d.ts.map +1 -0
- package/dist/tools/get-embed-code.js +214 -0
- package/dist/tools/get-embed-code.js.map +1 -0
- package/dist/tools/insights/analyze-share-links.d.ts +159 -0
- package/dist/tools/insights/analyze-share-links.d.ts.map +1 -0
- package/dist/tools/insights/analyze-share-links.js +314 -0
- package/dist/tools/insights/analyze-share-links.js.map +1 -0
- package/dist/tools/insights/insight-cache.d.ts +36 -0
- package/dist/tools/insights/insight-cache.d.ts.map +1 -0
- package/dist/tools/insights/insight-cache.js +98 -0
- package/dist/tools/insights/insight-cache.js.map +1 -0
- package/dist/tools/insights/report-asset-activation.d.ts +149 -0
- package/dist/tools/insights/report-asset-activation.d.ts.map +1 -0
- package/dist/tools/insights/report-asset-activation.js +380 -0
- package/dist/tools/insights/report-asset-activation.js.map +1 -0
- package/dist/tools/insights/report-stale-assets.d.ts +120 -0
- package/dist/tools/insights/report-stale-assets.d.ts.map +1 -0
- package/dist/tools/insights/report-stale-assets.js +281 -0
- package/dist/tools/insights/report-stale-assets.js.map +1 -0
- package/dist/tools/insights/report-top-assets.d.ts +139 -0
- package/dist/tools/insights/report-top-assets.d.ts.map +1 -0
- package/dist/tools/insights/report-top-assets.js +407 -0
- package/dist/tools/insights/report-top-assets.js.map +1 -0
- package/dist/tools/list-categories.d.ts +127 -0
- package/dist/tools/list-categories.d.ts.map +1 -0
- package/dist/tools/list-categories.js +68 -0
- package/dist/tools/list-categories.js.map +1 -0
- package/dist/tools/list-collections.d.ts +127 -0
- package/dist/tools/list-collections.d.ts.map +1 -0
- package/dist/tools/list-collections.js +53 -0
- package/dist/tools/list-collections.js.map +1 -0
- package/dist/tools/list-custom-fields.d.ts +125 -0
- package/dist/tools/list-custom-fields.d.ts.map +1 -0
- package/dist/tools/list-custom-fields.js +51 -0
- package/dist/tools/list-custom-fields.js.map +1 -0
- package/dist/tools/list-share-links.d.ts +192 -0
- package/dist/tools/list-share-links.d.ts.map +1 -0
- package/dist/tools/list-share-links.js +92 -0
- package/dist/tools/list-share-links.js.map +1 -0
- package/dist/tools/list-workspaces.d.ts +88 -0
- package/dist/tools/list-workspaces.d.ts.map +1 -0
- package/dist/tools/list-workspaces.js +71 -0
- package/dist/tools/list-workspaces.js.map +1 -0
- package/dist/tools/move-asset.d.ts +48 -0
- package/dist/tools/move-asset.d.ts.map +1 -0
- package/dist/tools/move-asset.js +85 -0
- package/dist/tools/move-asset.js.map +1 -0
- package/dist/tools/rename-asset.d.ts +88 -0
- package/dist/tools/rename-asset.d.ts.map +1 -0
- package/dist/tools/rename-asset.js +100 -0
- package/dist/tools/rename-asset.js.map +1 -0
- package/dist/tools/rename-folder.d.ts +55 -0
- package/dist/tools/rename-folder.d.ts.map +1 -0
- package/dist/tools/rename-folder.js +101 -0
- package/dist/tools/rename-folder.js.map +1 -0
- package/dist/tools/revoke-share-link.d.ts +55 -0
- package/dist/tools/revoke-share-link.d.ts.map +1 -0
- package/dist/tools/revoke-share-link.js +77 -0
- package/dist/tools/revoke-share-link.js.map +1 -0
- package/dist/tools/search/facets.d.ts +34 -0
- package/dist/tools/search/facets.d.ts.map +1 -0
- package/dist/tools/search/facets.js +147 -0
- package/dist/tools/search/facets.js.map +1 -0
- package/dist/tools/search/filter-builder.d.ts +33 -0
- package/dist/tools/search/filter-builder.d.ts.map +1 -0
- package/dist/tools/search/filter-builder.js +111 -0
- package/dist/tools/search/filter-builder.js.map +1 -0
- package/dist/tools/search/search-assets.d.ts +41 -0
- package/dist/tools/search/search-assets.d.ts.map +1 -0
- package/dist/tools/search/search-assets.js +162 -0
- package/dist/tools/search/search-assets.js.map +1 -0
- package/dist/tools/search/search-collections.d.ts +35 -0
- package/dist/tools/search/search-collections.d.ts.map +1 -0
- package/dist/tools/search/search-collections.js +103 -0
- package/dist/tools/search/search-collections.js.map +1 -0
- package/dist/tools/search/types.d.ts +1047 -0
- package/dist/tools/search/types.d.ts.map +1 -0
- package/dist/tools/search/types.js +216 -0
- package/dist/tools/search/types.js.map +1 -0
- package/dist/tools/update-asset-metadata.d.ts +78 -0
- package/dist/tools/update-asset-metadata.d.ts.map +1 -0
- package/dist/tools/update-asset-metadata.js +203 -0
- package/dist/tools/update-asset-metadata.js.map +1 -0
- package/dist/tools/update-collection.d.ts +69 -0
- package/dist/tools/update-collection.d.ts.map +1 -0
- package/dist/tools/update-collection.js +142 -0
- package/dist/tools/update-collection.js.map +1 -0
- package/dist/tools/view-category-contents.d.ts +231 -0
- package/dist/tools/view-category-contents.d.ts.map +1 -0
- package/dist/tools/view-category-contents.js +97 -0
- package/dist/tools/view-category-contents.js.map +1 -0
- package/dist/types.d.ts +1326 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +288 -0
- package/dist/types.js.map +1 -0
- package/dist/typesense.d.ts +84 -0
- package/dist/typesense.d.ts.map +1 -0
- package/dist/typesense.js +243 -0
- package/dist/typesense.js.map +1 -0
- package/docs/api-field-verification.md +244 -0
- package/docs/deployment-runbook.md +446 -0
- package/docs/security-review.md +195 -0
- package/docs/typesense-filter-schema.md +262 -0
- package/docs/verified-endpoints.md +38 -0
- package/package.json +72 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createToolError, mapHttpStatusToErrorCode, } from './conventions/errors.js';
|
|
3
|
+
import { RateLimitedError, TokenBucketRateLimiter, withRetry, } from './conventions/rate-limiter.js';
|
|
4
|
+
import { generateRequestId, logger as defaultLogger } from './conventions/logger.js';
|
|
5
|
+
import { AssetCustomFieldSchema, CategoryListSchema, CollectionListSchema, CustomFieldListSchema, DigitalAssetSchema, GenerateEmbedCodeResponseSchema, GenerateShareLinkResponseSchema, PaginatedAssetsEnvelopeSchema, ShareLinkListEnvelopeSchema, SimpleMessageEnvelopeSchema, unwrapEnvelope, } from './types.js';
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
7
|
+
/**
|
|
8
|
+
* Schema for the `check-workspace-access` response payload.
|
|
9
|
+
* The endpoint may wrap fields under a top-level `data` envelope, so we
|
|
10
|
+
* accept either shape and normalise downstream.
|
|
11
|
+
*/
|
|
12
|
+
const WorkspaceAccessSchema = z.object({
|
|
13
|
+
has_access: z.boolean().optional(),
|
|
14
|
+
status: z.boolean().optional(),
|
|
15
|
+
message: z.string().optional(),
|
|
16
|
+
});
|
|
17
|
+
const WorkspaceAccessEnvelopeSchema = z.object({
|
|
18
|
+
data: WorkspaceAccessSchema.optional(),
|
|
19
|
+
status: z.boolean().optional(),
|
|
20
|
+
message: z.string().optional(),
|
|
21
|
+
has_access: z.boolean().optional(),
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* HTTP client for the Collage DAM REST API.
|
|
25
|
+
*
|
|
26
|
+
* Responsibilities:
|
|
27
|
+
* - Bearer JWT auth (token from `COLLAGE_API_KEY`)
|
|
28
|
+
* - Workspace scoping via `?url_workspace_id=` on every request
|
|
29
|
+
* - Token-bucket rate limiting
|
|
30
|
+
* - Retry with exponential backoff on 429/5xx (honours `Retry-After`)
|
|
31
|
+
* - HTTP status → canonical `McpToolError` mapping
|
|
32
|
+
* - Per-request `request_id` propagation for tracing
|
|
33
|
+
*/
|
|
34
|
+
export class CollageClient {
|
|
35
|
+
baseUrl;
|
|
36
|
+
apiKey;
|
|
37
|
+
workspaceId;
|
|
38
|
+
limiter;
|
|
39
|
+
log;
|
|
40
|
+
constructor(opts) {
|
|
41
|
+
this.baseUrl = opts.config.collageApiUrl.endsWith('/')
|
|
42
|
+
? opts.config.collageApiUrl
|
|
43
|
+
: `${opts.config.collageApiUrl}/`;
|
|
44
|
+
this.apiKey = opts.config.collageApiKey;
|
|
45
|
+
this.workspaceId = opts.config.collageWorkspaceId;
|
|
46
|
+
this.limiter = opts.rateLimiter ?? new TokenBucketRateLimiter({ maxRps: opts.config.maxRps });
|
|
47
|
+
this.log = opts.logger ?? defaultLogger;
|
|
48
|
+
}
|
|
49
|
+
/** Workspace ID associated with this client (read-only). */
|
|
50
|
+
get scopedWorkspaceId() {
|
|
51
|
+
return this.workspaceId;
|
|
52
|
+
}
|
|
53
|
+
async get(path, options) {
|
|
54
|
+
return this.request('GET', path, options);
|
|
55
|
+
}
|
|
56
|
+
async post(path, options) {
|
|
57
|
+
return this.request('POST', path, options);
|
|
58
|
+
}
|
|
59
|
+
async put(path, options) {
|
|
60
|
+
return this.request('PUT', path, options);
|
|
61
|
+
}
|
|
62
|
+
async patch(path, options) {
|
|
63
|
+
return this.request('PATCH', path, options);
|
|
64
|
+
}
|
|
65
|
+
async delete(path, options) {
|
|
66
|
+
return this.request('DELETE', path, options);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Perform a startup sanity check that the configured token can reach the
|
|
70
|
+
* configured workspace. Returns a normalised access result.
|
|
71
|
+
*
|
|
72
|
+
* Used by `src/index.ts` on boot to fail fast on misconfiguration.
|
|
73
|
+
*/
|
|
74
|
+
async checkWorkspaceAccess() {
|
|
75
|
+
const result = await this.get('check-workspace-access');
|
|
76
|
+
if (!result.ok) {
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
const parsed = WorkspaceAccessEnvelopeSchema.safeParse(result.data);
|
|
80
|
+
if (!parsed.success) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
request_id: result.request_id,
|
|
84
|
+
error: createToolError('UPSTREAM', 'check-workspace-access returned an unexpected shape', {
|
|
85
|
+
cause: { request_id: result.request_id },
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const inner = parsed.data.data ?? {};
|
|
90
|
+
const hasAccess = inner.has_access ?? parsed.data.has_access ?? inner.status ?? parsed.data.status ?? false;
|
|
91
|
+
const message = inner.message ?? parsed.data.message;
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
request_id: result.request_id,
|
|
95
|
+
data: { has_access: hasAccess, ...(message !== undefined ? { message } : {}) },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* GET /digital-assets/category/all-category-list
|
|
100
|
+
*
|
|
101
|
+
* Returns ROOT-LEVEL folders only — upstream is flat-by-design. Verified
|
|
102
|
+
* against `Admin-Frontend/pages/_workspace_id/dam/folders/index.vue` (the
|
|
103
|
+
* Folders index page calls this endpoint with the same params and only
|
|
104
|
+
* shows roots; nested folders load via `sub-category-list` on click).
|
|
105
|
+
*
|
|
106
|
+
* Use `listAllCategoriesRecursive()` when you need the full tree.
|
|
107
|
+
*
|
|
108
|
+
* Requires `workspace_id` in the query in addition to the global
|
|
109
|
+
* `?url_workspace_id=`. Without both, the upstream returns 402
|
|
110
|
+
* "Workspace is not available." (Ross Durbin, 2026-05-01.)
|
|
111
|
+
*/
|
|
112
|
+
async listCategories() {
|
|
113
|
+
return this.parsedGet('digital-assets/category/all-category-list', CategoryListSchema, { query: { workspace_id: this.workspaceId } });
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Walks the workspace folder tree by combining `listCategories()` (roots)
|
|
117
|
+
* with `listSubCategories()` for each parent. Returns a flat list with
|
|
118
|
+
* `parent_id` populated on every row — null for roots — so callers can
|
|
119
|
+
* reconstruct the tree without recursing themselves.
|
|
120
|
+
*
|
|
121
|
+
* Behavior:
|
|
122
|
+
* - Breadth-first traversal, deduped by category id.
|
|
123
|
+
* - Bounded by `maxNodes` (default 5000) and `maxDepth` (default 16) to
|
|
124
|
+
* keep audit/enumeration runs predictable on huge workspaces.
|
|
125
|
+
* - Any non-ok subcategory fetch aborts the walk and propagates the error.
|
|
126
|
+
*/
|
|
127
|
+
async listAllCategoriesRecursive(opts) {
|
|
128
|
+
const maxNodes = opts?.maxNodes ?? 5000;
|
|
129
|
+
const maxDepth = opts?.maxDepth ?? 16;
|
|
130
|
+
const roots = await this.listCategories();
|
|
131
|
+
if (!roots.ok)
|
|
132
|
+
return roots;
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
const out = [];
|
|
135
|
+
let frontier = [];
|
|
136
|
+
for (const r of roots.data) {
|
|
137
|
+
const key = String(r.id);
|
|
138
|
+
if (seen.has(key))
|
|
139
|
+
continue;
|
|
140
|
+
seen.add(key);
|
|
141
|
+
const root = { ...r, parent_id: r.parent_id ?? null };
|
|
142
|
+
out.push(root);
|
|
143
|
+
if (out.length >= maxNodes)
|
|
144
|
+
break;
|
|
145
|
+
frontier.push({ cat: root, depth: 0 });
|
|
146
|
+
}
|
|
147
|
+
while (frontier.length > 0 && out.length < maxNodes) {
|
|
148
|
+
const next = [];
|
|
149
|
+
for (const { cat, depth } of frontier) {
|
|
150
|
+
if (depth + 1 > maxDepth)
|
|
151
|
+
continue;
|
|
152
|
+
const subs = await this.listSubCategories(cat.id);
|
|
153
|
+
if (!subs.ok) {
|
|
154
|
+
return { ok: false, error: subs.error, request_id: subs.request_id };
|
|
155
|
+
}
|
|
156
|
+
for (const child of subs.data) {
|
|
157
|
+
const key = String(child.id);
|
|
158
|
+
if (seen.has(key))
|
|
159
|
+
continue;
|
|
160
|
+
seen.add(key);
|
|
161
|
+
const node = { ...child, parent_id: child.parent_id ?? cat.id };
|
|
162
|
+
out.push(node);
|
|
163
|
+
if (out.length >= maxNodes)
|
|
164
|
+
break;
|
|
165
|
+
next.push({ cat: node, depth: depth + 1 });
|
|
166
|
+
}
|
|
167
|
+
if (out.length >= maxNodes)
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
frontier = next;
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, data: out, request_id: generateRequestId() };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* GET /digital-assets/category/sub-category-list
|
|
176
|
+
* Returns the immediate children of a folder. Same row shape as
|
|
177
|
+
* `all-category-list`. Requires `workspace_id` + `category_id` in the
|
|
178
|
+
* query in addition to the global `?url_workspace_id=`.
|
|
179
|
+
*/
|
|
180
|
+
async listSubCategories(categoryId) {
|
|
181
|
+
return this.parsedGet('digital-assets/category/sub-category-list', CategoryListSchema, {
|
|
182
|
+
query: {
|
|
183
|
+
workspace_id: this.workspaceId,
|
|
184
|
+
category_id: String(categoryId),
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* GET /digital-assets/instance/get
|
|
190
|
+
* Returns the portal/instance configuration for the workspace. Shape is
|
|
191
|
+
* upstream-defined and not yet narrowed; callers should treat the result
|
|
192
|
+
* as opaque until a tool needs specific fields.
|
|
193
|
+
*/
|
|
194
|
+
async getInstance() {
|
|
195
|
+
return this.parsedGet('digital-assets/instance/get', z.unknown(), { query: { workspace_id: this.workspaceId } });
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* GET /digital-assets/dashboard/common-data
|
|
199
|
+
* Returns workspace dashboard stats (asset counts, storage, etc.). Shape
|
|
200
|
+
* is upstream-defined; narrow when wiring an insights tool.
|
|
201
|
+
*/
|
|
202
|
+
async getDashboardCommonData() {
|
|
203
|
+
return this.parsedGet('digital-assets/dashboard/common-data', z.unknown(), { query: { workspace_id: this.workspaceId } });
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* GET /digital-assets/get-recent-uploaded
|
|
207
|
+
* Paginated list of recently-uploaded assets. Shape mirrors the standard
|
|
208
|
+
* paginated-asset response but is left opaque here; narrow when wiring
|
|
209
|
+
* the `collage://assets/recent` resource.
|
|
210
|
+
*/
|
|
211
|
+
async listRecentUploaded(params) {
|
|
212
|
+
return this.parsedGet('digital-assets/get-recent-uploaded', z.unknown(), {
|
|
213
|
+
query: {
|
|
214
|
+
workspace_id: this.workspaceId,
|
|
215
|
+
page: params.page,
|
|
216
|
+
total_record: params.totalRecord,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* POST /digital-assets/category/add (parent_id absent)
|
|
222
|
+
* POST /digital-assets/category/add-sub-category (parent_id present)
|
|
223
|
+
*
|
|
224
|
+
* Source: `Admin-Frontend/components/dam/Dialogs/miniUploadBackdrop.vue:358,366`.
|
|
225
|
+
* Body shape:
|
|
226
|
+
* - top-level: { workspace_id, folder_name }
|
|
227
|
+
* - sub-folder: { workspace_id, folder_name, category_id }
|
|
228
|
+
* The two endpoints differ only by path; the body for sub-folders adds the
|
|
229
|
+
* parent's id under the (confusingly named) `category_id` key.
|
|
230
|
+
*
|
|
231
|
+
* Returns the new folder's id when the upstream surfaces it. The Collage
|
|
232
|
+
* envelope nests the projection under `data.id`; we tolerate either
|
|
233
|
+
* `data.id` or a top-level `id` field.
|
|
234
|
+
*/
|
|
235
|
+
async createCategory(input) {
|
|
236
|
+
const path = input.parent_id !== undefined && input.parent_id !== null
|
|
237
|
+
? 'digital-assets/category/add-sub-category'
|
|
238
|
+
: 'digital-assets/category/add';
|
|
239
|
+
const body = {
|
|
240
|
+
workspace_id: this.workspaceId,
|
|
241
|
+
folder_name: input.folder_name,
|
|
242
|
+
};
|
|
243
|
+
if (input.parent_id !== undefined && input.parent_id !== null) {
|
|
244
|
+
body['category_id'] = input.parent_id;
|
|
245
|
+
}
|
|
246
|
+
const raw = await this.post(path, { body });
|
|
247
|
+
if (!raw.ok)
|
|
248
|
+
return raw;
|
|
249
|
+
const id = extractCreatedCategoryId(raw.data);
|
|
250
|
+
if (id === null) {
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
request_id: raw.request_id,
|
|
254
|
+
error: createToolError('UPSTREAM', 'category/add response missing folder id', { cause: { request_id: raw.request_id } }),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return { ok: true, request_id: raw.request_id, data: { id } };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* POST /digital-assets/category/rename/{id}
|
|
261
|
+
* Source: `Admin-Frontend/pages/_workspace_id/dam/folders/index.vue:1281`.
|
|
262
|
+
* Body: { workspace_id, folder_name, description? }
|
|
263
|
+
*/
|
|
264
|
+
async renameCategory(categoryId, input) {
|
|
265
|
+
const body = {
|
|
266
|
+
workspace_id: this.workspaceId,
|
|
267
|
+
folder_name: input.folder_name,
|
|
268
|
+
};
|
|
269
|
+
if (input.description !== undefined) {
|
|
270
|
+
body['description'] = input.description ?? '';
|
|
271
|
+
}
|
|
272
|
+
const raw = await this.post(`digital-assets/category/rename/${encodeURIComponent(String(categoryId))}`, { body });
|
|
273
|
+
if (!raw.ok)
|
|
274
|
+
return raw;
|
|
275
|
+
const message = extractMessage(raw.data);
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
request_id: raw.request_id,
|
|
279
|
+
data: message !== undefined ? { message } : {},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* POST /digital-assets/multiple-file-folder-move
|
|
284
|
+
* Source: `Admin-Frontend/components/dam/Dialogs/FolderDialogs/FolderDialog.vue:1471`.
|
|
285
|
+
* Body: { workspace_id, assets_ids: number[], category_ids: number[], move_id: destFolderId }
|
|
286
|
+
*
|
|
287
|
+
* `move_id` is the destination category id. Pass null to move into the
|
|
288
|
+
* uncategorised root (the frontend uses null when destination is "no
|
|
289
|
+
* folder" — verify against your workspace before relying on that).
|
|
290
|
+
*/
|
|
291
|
+
async moveAssetsAndFolders(input) {
|
|
292
|
+
const raw = await this.post('digital-assets/multiple-file-folder-move', {
|
|
293
|
+
body: {
|
|
294
|
+
workspace_id: this.workspaceId,
|
|
295
|
+
assets_ids: input.asset_ids,
|
|
296
|
+
category_ids: input.folder_ids,
|
|
297
|
+
move_id: input.destination_id,
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
if (!raw.ok)
|
|
301
|
+
return raw;
|
|
302
|
+
const message = extractMessage(raw.data);
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
request_id: raw.request_id,
|
|
306
|
+
data: message !== undefined ? { message } : {},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* GET /digital-assets/collection/get-all
|
|
311
|
+
* Returns the user's saved DAM collections for the workspace.
|
|
312
|
+
*/
|
|
313
|
+
async listCollections() {
|
|
314
|
+
return this.parsedGet('digital-assets/collection/get-all', CollectionListSchema);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* GET /digital-assets/collection/get-all (filtered to a single id).
|
|
318
|
+
*
|
|
319
|
+
* The Collage REST API exposes only a list endpoint for collections;
|
|
320
|
+
* there is no per-id route. We fetch the full list and filter
|
|
321
|
+
* client-side so the MCP `get_collection` tool has the same single-row
|
|
322
|
+
* read affordance as the rest of the read-side API surface. For
|
|
323
|
+
* workspaces with thousands of collections this trade-off is fine:
|
|
324
|
+
* the list is small (< few hundred typical) and the upstream caches it.
|
|
325
|
+
*/
|
|
326
|
+
async getCollection(collectionId) {
|
|
327
|
+
const list = await this.listCollections();
|
|
328
|
+
if (!list.ok)
|
|
329
|
+
return list;
|
|
330
|
+
const target = String(collectionId);
|
|
331
|
+
const match = list.data.find((c) => String(c.id) === target);
|
|
332
|
+
if (match === undefined) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
request_id: list.request_id,
|
|
336
|
+
error: createToolError('NOT_FOUND', `Collection ${collectionId} not found in workspace.`, { cause: { request_id: list.request_id } }),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return { ok: true, data: match, request_id: list.request_id };
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* GET /digital-assets/custom-field/list
|
|
343
|
+
*
|
|
344
|
+
* Returns the workspace-scoped custom-field definitions. Source:
|
|
345
|
+
* `Admin-Frontend/pages/_workspace_id/workspace-settings/custom-fields/index.vue:144`.
|
|
346
|
+
* Each row is shaped `{ id, field_label, field_type, status, ... }`.
|
|
347
|
+
* The endpoint requires `workspace_id` in the query string in addition
|
|
348
|
+
* to the global `?url_workspace_id=` (Collage uses both names — the
|
|
349
|
+
* frontend always sends both).
|
|
350
|
+
*/
|
|
351
|
+
async listCustomFields() {
|
|
352
|
+
const raw = await this.get('digital-assets/custom-field/list', {
|
|
353
|
+
query: { workspace_id: this.workspaceId },
|
|
354
|
+
});
|
|
355
|
+
if (!raw.ok)
|
|
356
|
+
return raw;
|
|
357
|
+
try {
|
|
358
|
+
const parsed = unwrapEnvelope(raw.data, CustomFieldListSchema);
|
|
359
|
+
return { ok: true, data: parsed, request_id: raw.request_id };
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
request_id: raw.request_id,
|
|
365
|
+
error: createToolError('UPSTREAM', `Schema mismatch on custom-field/list: ${err instanceof Error ? err.message : String(err)}`, { cause: { request_id: raw.request_id } }),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* POST /digital-assets/view-detail
|
|
371
|
+
* Returns full metadata for a single asset.
|
|
372
|
+
*
|
|
373
|
+
* Field-name quirk: the body param is `digital_assets_id`, NOT `asset_id`.
|
|
374
|
+
* The response is a richer projection than the raw `digital_assets` row —
|
|
375
|
+
* it joins in tag rows, breadcrumb, formatted file metadata, and signed
|
|
376
|
+
* S3 thumbnail/compress URLs (24h expiry).
|
|
377
|
+
*
|
|
378
|
+
* Read paths (`getAssetCurrentName`, `getAssetMetadata`, `getAssetTags`)
|
|
379
|
+
* are intentionally implemented as projections of this single response —
|
|
380
|
+
* see the adapters in `src/tools/client-adapters.ts`. There is no
|
|
381
|
+
* dedicated REST endpoint per field; the Admin-Frontend reads everything
|
|
382
|
+
* via this same `view-detail` call.
|
|
383
|
+
*/
|
|
384
|
+
async getAssetDetails(assetId) {
|
|
385
|
+
const raw = await this.post('digital-assets/view-detail', {
|
|
386
|
+
body: { digital_assets_id: assetId },
|
|
387
|
+
});
|
|
388
|
+
if (!raw.ok)
|
|
389
|
+
return raw;
|
|
390
|
+
try {
|
|
391
|
+
const parsed = unwrapEnvelope(raw.data, DigitalAssetSchema);
|
|
392
|
+
return { ok: true, data: parsed, request_id: raw.request_id };
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
return {
|
|
396
|
+
ok: false,
|
|
397
|
+
request_id: raw.request_id,
|
|
398
|
+
error: createToolError('UPSTREAM', `Schema mismatch on view-detail: ${err instanceof Error ? err.message : String(err)}`, { cause: { request_id: raw.request_id } }),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* GET /digital-assets/embed-code-list
|
|
404
|
+
* Returns the embed-code share-link configurations.
|
|
405
|
+
*
|
|
406
|
+
* Schema is intentionally `unknown` until we observe a live response —
|
|
407
|
+
* keep the raw passthrough so the smoke tool can render whatever the
|
|
408
|
+
* upstream returns without rejecting fields we have not modelled yet.
|
|
409
|
+
*/
|
|
410
|
+
async listEmbedCodes() {
|
|
411
|
+
return this.get('digital-assets/embed-code-list');
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* POST /digital-assets/collection/create
|
|
415
|
+
* Body: { name, description? }
|
|
416
|
+
* Returns: envelope with { id, name, description, ... }
|
|
417
|
+
*/
|
|
418
|
+
async createCollection(body) {
|
|
419
|
+
const raw = await this.post('digital-assets/collection/create', {
|
|
420
|
+
body,
|
|
421
|
+
});
|
|
422
|
+
if (!raw.ok)
|
|
423
|
+
return raw;
|
|
424
|
+
return parseCollectionMutationResponse(raw);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* POST /digital-assets/collection/update/{id}
|
|
428
|
+
* Body: { name?, description? }
|
|
429
|
+
* Returns: envelope with the updated collection projection. The live
|
|
430
|
+
* response on BREZ (verified 2026-04-29) omits `id` at the top level —
|
|
431
|
+
* the path id is the canonical identity, so we fall it back through.
|
|
432
|
+
*/
|
|
433
|
+
async updateCollection(collectionId, body) {
|
|
434
|
+
const raw = await this.post(`digital-assets/collection/update/${encodeURIComponent(collectionId)}`, { body });
|
|
435
|
+
if (!raw.ok)
|
|
436
|
+
return raw;
|
|
437
|
+
return parseCollectionMutationResponse(raw, collectionId);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* POST /digital-assets/collection/{id}/add-assets
|
|
441
|
+
* Body: { assets_id: (string|number)[] }
|
|
442
|
+
*/
|
|
443
|
+
async addAssetsToCollection(collectionId, assetIds) {
|
|
444
|
+
const raw = await this.post(`digital-assets/collection/${encodeURIComponent(collectionId)}/add-assets`, { body: { assets_id: assetIds } });
|
|
445
|
+
if (!raw.ok)
|
|
446
|
+
return raw;
|
|
447
|
+
const message = extractMessage(raw.data);
|
|
448
|
+
return { ok: true, data: message !== undefined ? { message } : {}, request_id: raw.request_id };
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* POST /digital-assets/collection/{id}/remove-assets
|
|
452
|
+
* Body: { assets_id: (string|number)[] }
|
|
453
|
+
*/
|
|
454
|
+
async removeAssetsFromCollection(collectionId, assetIds) {
|
|
455
|
+
const raw = await this.post(`digital-assets/collection/${encodeURIComponent(collectionId)}/remove-assets`, { body: { assets_id: assetIds } });
|
|
456
|
+
if (!raw.ok)
|
|
457
|
+
return raw;
|
|
458
|
+
const message = extractMessage(raw.data);
|
|
459
|
+
return { ok: true, data: message !== undefined ? { message } : {}, request_id: raw.request_id };
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* GET /digital-assets/category/view-files-with-category
|
|
463
|
+
*
|
|
464
|
+
* Folder-scoped paginated asset listing. Used as the REST fallback for
|
|
465
|
+
* workspace-wide enumeration (Typesense is the primary path but is not
|
|
466
|
+
* yet wired). Response shape (verified via Admin-Frontend
|
|
467
|
+
* `pages/_workspace_id/dam/folders/_id/index.vue:3357`):
|
|
468
|
+
*
|
|
469
|
+
* { data: { assets_with_folder: { data: [...], last_page, total }, ... } }
|
|
470
|
+
*
|
|
471
|
+
* Each row in `assets_with_folder.data` is the raw `digital_assets` row
|
|
472
|
+
* (NOT the `view-detail` projection), so tag projection here only sees
|
|
473
|
+
* what `category/view-files-with-category` returns — which historically
|
|
474
|
+
* does NOT include the joined `tags` array. See `enumerateAssets` for
|
|
475
|
+
* how the audit tool tolerates that.
|
|
476
|
+
*/
|
|
477
|
+
async listAssetsByCategory(categoryId, page, opts) {
|
|
478
|
+
const raw = await this.get('digital-assets/category/view-files-with-category', {
|
|
479
|
+
query: {
|
|
480
|
+
workspace_id: this.workspaceId,
|
|
481
|
+
category_id: categoryId,
|
|
482
|
+
page,
|
|
483
|
+
sort_by: opts?.sortBy ?? 'ASC',
|
|
484
|
+
sort_value: opts?.sortValue ?? 'display_file_name',
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
if (!raw.ok)
|
|
488
|
+
return raw;
|
|
489
|
+
try {
|
|
490
|
+
const parsed = unwrapEnvelope(raw.data, PaginatedAssetsEnvelopeSchema);
|
|
491
|
+
return {
|
|
492
|
+
ok: true,
|
|
493
|
+
request_id: raw.request_id,
|
|
494
|
+
data: {
|
|
495
|
+
assets: parsed.assets_with_folder.data,
|
|
496
|
+
lastPage: parsed.assets_with_folder.last_page ?? 1,
|
|
497
|
+
total: parsed.assets_with_folder.total ?? parsed.assets_with_folder.data.length,
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
return {
|
|
503
|
+
ok: false,
|
|
504
|
+
request_id: raw.request_id,
|
|
505
|
+
error: createToolError('UPSTREAM', `Schema mismatch on view-files-with-category: ${err instanceof Error ? err.message : String(err)}`, { cause: { request_id: raw.request_id } }),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Workspace-wide flat asset enumeration via folder iteration.
|
|
511
|
+
*
|
|
512
|
+
* Walks the FULL folder tree via `listAllCategoriesRecursive()` (roots
|
|
513
|
+
* plus every sub-folder) and paginates `listAssetsByCategory()` per
|
|
514
|
+
* folder to produce a flat `{id, tags?, ...}[]`. Used by the
|
|
515
|
+
* audit-tagging-hygiene tool. Tags come from `view-detail`-shaped rows
|
|
516
|
+
* when present, otherwise the row's `tags` field (often missing on the
|
|
517
|
+
* paginator response — callers must tolerate `tags=[]`).
|
|
518
|
+
*
|
|
519
|
+
* Prior to FU3 (2026-05-04) this only walked root folders, so any asset
|
|
520
|
+
* living in a nested folder was invisible to the audit.
|
|
521
|
+
*/
|
|
522
|
+
async enumerateAssets(opts) {
|
|
523
|
+
const cats = await this.listAllCategoriesRecursive();
|
|
524
|
+
if (!cats.ok)
|
|
525
|
+
return { ok: false, error: cats.error, request_id: cats.request_id };
|
|
526
|
+
const limit = opts?.maxAssets ?? 5000;
|
|
527
|
+
const seen = new Set();
|
|
528
|
+
const out = [];
|
|
529
|
+
for (const cat of cats.data) {
|
|
530
|
+
if (out.length >= limit)
|
|
531
|
+
break;
|
|
532
|
+
let page = 1;
|
|
533
|
+
let lastPage = 1;
|
|
534
|
+
do {
|
|
535
|
+
const res = await this.listAssetsByCategory(cat.id, page);
|
|
536
|
+
if (!res.ok)
|
|
537
|
+
return { ok: false, error: res.error, request_id: res.request_id };
|
|
538
|
+
for (const row of res.data.assets) {
|
|
539
|
+
const id = String(row.id);
|
|
540
|
+
if (seen.has(id))
|
|
541
|
+
continue;
|
|
542
|
+
seen.add(id);
|
|
543
|
+
const tagNames = Array.isArray(row.tags)
|
|
544
|
+
? row.tags.map((t) => t.tag_name)
|
|
545
|
+
: [];
|
|
546
|
+
out.push({ id, tags: tagNames });
|
|
547
|
+
if (out.length >= limit)
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
lastPage = res.data.lastPage;
|
|
551
|
+
page += 1;
|
|
552
|
+
} while (page <= lastPage && out.length < limit);
|
|
553
|
+
}
|
|
554
|
+
return { ok: true, data: out, request_id: generateRequestId() };
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* POST /digital-assets/update-with-field
|
|
558
|
+
*
|
|
559
|
+
* Single-field update used by the Admin-Frontend rename and description
|
|
560
|
+
* edit flows. Source: `pages/_workspace_id/dam/files/_id.vue:2329` and
|
|
561
|
+
* `components/dam/SearchAssets.vue:1206`. Body shape:
|
|
562
|
+
*
|
|
563
|
+
* { workspace_id, digital_assets_id, field_name, field_value }
|
|
564
|
+
*
|
|
565
|
+
* Note `digital_assets_id` (not `asset_id`) and `workspace_id` is in the
|
|
566
|
+
* BODY too — even though the same value is also on the URL via
|
|
567
|
+
* `?url_workspace_id=`. The frontend always sends both; we mirror that.
|
|
568
|
+
*/
|
|
569
|
+
async updateAssetField(assetId, fieldName, fieldValue) {
|
|
570
|
+
const raw = await this.post('digital-assets/update-with-field', {
|
|
571
|
+
body: {
|
|
572
|
+
workspace_id: this.workspaceId,
|
|
573
|
+
digital_assets_id: assetId,
|
|
574
|
+
field_name: fieldName,
|
|
575
|
+
field_value: fieldValue,
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
if (!raw.ok)
|
|
579
|
+
return raw;
|
|
580
|
+
const parsed = SimpleMessageEnvelopeSchema.safeParse(raw.data);
|
|
581
|
+
const message = parsed.success ? parsed.data.message : undefined;
|
|
582
|
+
return {
|
|
583
|
+
ok: true,
|
|
584
|
+
request_id: raw.request_id,
|
|
585
|
+
data: message !== undefined ? { message } : {},
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* POST /digital-assets/update-with-field with `field_name: 'display_file_name'`.
|
|
590
|
+
*
|
|
591
|
+
* The Admin-Frontend rename UI also concatenates the file extension
|
|
592
|
+
* (`${name}.${file_type}`). We do NOT do that here — callers pass the
|
|
593
|
+
* exact `display_file_name` they want, including any extension. This
|
|
594
|
+
* keeps the client a thin pass-through and lets the rename tool decide
|
|
595
|
+
* its own extension policy.
|
|
596
|
+
*/
|
|
597
|
+
async renameAsset(assetId, newName) {
|
|
598
|
+
return this.updateAssetField(assetId, 'display_file_name', newName);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* GET /digital-assets/get-custom-fields
|
|
602
|
+
*
|
|
603
|
+
* Returns the per-asset custom-field projection (id, name, field_type,
|
|
604
|
+
* value, ...). Source: `pages/_workspace_id/dam/files/_id.vue:1319`.
|
|
605
|
+
* Used as the read side for `update_asset_metadata` so the dry-run plan
|
|
606
|
+
* can render a real diff. Schema is defensive — fields beyond
|
|
607
|
+
* {id, name, value} are passed through.
|
|
608
|
+
*/
|
|
609
|
+
async getAssetCustomFields(assetId) {
|
|
610
|
+
// Note: this endpoint requires `workspace_id` in the query in addition to
|
|
611
|
+
// the global `?url_workspace_id=` (different param name — field-name
|
|
612
|
+
// mismatch confirmed against BREZ on 2026-04-29).
|
|
613
|
+
const raw = await this.get('digital-assets/get-custom-fields', {
|
|
614
|
+
query: { asset_id: assetId, workspace_id: this.workspaceId },
|
|
615
|
+
});
|
|
616
|
+
if (!raw.ok)
|
|
617
|
+
return raw;
|
|
618
|
+
try {
|
|
619
|
+
const parsed = unwrapEnvelope(raw.data, z.array(AssetCustomFieldSchema));
|
|
620
|
+
return { ok: true, data: parsed, request_id: raw.request_id };
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
return {
|
|
624
|
+
ok: false,
|
|
625
|
+
request_id: raw.request_id,
|
|
626
|
+
error: createToolError('UPSTREAM', `Schema mismatch on get-custom-fields: ${err instanceof Error ? err.message : String(err)}`, { cause: { request_id: raw.request_id } }),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* POST /digital-assets/update-custom-fields
|
|
632
|
+
*
|
|
633
|
+
* Bulk custom-field update. Source: `pages/_workspace_id/dam/files/_id.vue:2894`
|
|
634
|
+
* Body shape:
|
|
635
|
+
*
|
|
636
|
+
* { workspace_id, asset_ids: [id], updated_data: [{ id, value, ... }] }
|
|
637
|
+
*
|
|
638
|
+
* Each entry in `updated_data` is the projection returned by
|
|
639
|
+
* `get-custom-fields` with its `value` overwritten — the upstream
|
|
640
|
+
* matches by the field's row id.
|
|
641
|
+
*/
|
|
642
|
+
async updateAssetCustomFields(assetId, updatedFields) {
|
|
643
|
+
const raw = await this.post('digital-assets/update-custom-fields', {
|
|
644
|
+
body: {
|
|
645
|
+
workspace_id: this.workspaceId,
|
|
646
|
+
asset_ids: [assetId],
|
|
647
|
+
updated_data: updatedFields,
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
if (!raw.ok)
|
|
651
|
+
return raw;
|
|
652
|
+
const parsed = SimpleMessageEnvelopeSchema.safeParse(raw.data);
|
|
653
|
+
const message = parsed.success ? parsed.data.message : undefined;
|
|
654
|
+
return {
|
|
655
|
+
ok: true,
|
|
656
|
+
request_id: raw.request_id,
|
|
657
|
+
data: message !== undefined ? { message } : {},
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* POST /digital-assets/add-tags-to-multiple-file
|
|
662
|
+
*
|
|
663
|
+
* Source: `components/dam/AssetList/AddTags.vue:86`. Body:
|
|
664
|
+
*
|
|
665
|
+
* { workspace_id, digital_assets_ids: [id, ...], tags: ['name', ...] }
|
|
666
|
+
*
|
|
667
|
+
* `tags` is an array of plain string names — the upstream creates new
|
|
668
|
+
* tag rows on demand. Idempotent on the server (duplicate adds are
|
|
669
|
+
* tolerated, surfaced via the `message` field).
|
|
670
|
+
*/
|
|
671
|
+
async addTagsToAssets(assetIds, tagNames) {
|
|
672
|
+
const raw = await this.post('digital-assets/add-tags-to-multiple-file', {
|
|
673
|
+
body: {
|
|
674
|
+
workspace_id: this.workspaceId,
|
|
675
|
+
digital_assets_ids: assetIds,
|
|
676
|
+
tags: tagNames,
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
if (!raw.ok)
|
|
680
|
+
return raw;
|
|
681
|
+
const parsed = SimpleMessageEnvelopeSchema.safeParse(raw.data);
|
|
682
|
+
const message = parsed.success ? parsed.data.message : undefined;
|
|
683
|
+
return {
|
|
684
|
+
ok: true,
|
|
685
|
+
request_id: raw.request_id,
|
|
686
|
+
data: message !== undefined ? { message } : {},
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* POST /digital-assets/delete-tag-from-multiple-file
|
|
691
|
+
*
|
|
692
|
+
* Source: `components/dam/Dialogs/AddTags/AddMultipleTags.vue:202` —
|
|
693
|
+
* the multi-select removal path uses `tag_name` (not `tag_id`), and
|
|
694
|
+
* `digital_assets_ids` is an array of `{ id }` objects. Single-asset
|
|
695
|
+
* removal uses `tag_id` instead. We use the multi-asset / `tag_name`
|
|
696
|
+
* shape so a single client method covers full-replace.
|
|
697
|
+
*
|
|
698
|
+
* { workspace_id, digital_assets_ids: [{ id }, ...], tag_name }
|
|
699
|
+
*
|
|
700
|
+
* One tag name per call — the upstream removes that tag from every
|
|
701
|
+
* provided asset.
|
|
702
|
+
*/
|
|
703
|
+
async removeTagFromAssets(assetIds, tagName) {
|
|
704
|
+
const raw = await this.post('digital-assets/delete-tag-from-multiple-file', {
|
|
705
|
+
body: {
|
|
706
|
+
workspace_id: this.workspaceId,
|
|
707
|
+
digital_assets_ids: assetIds.map((id) => ({ id })),
|
|
708
|
+
tag_name: tagName,
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
if (!raw.ok)
|
|
712
|
+
return raw;
|
|
713
|
+
const parsed = SimpleMessageEnvelopeSchema.safeParse(raw.data);
|
|
714
|
+
const message = parsed.success ? parsed.data.message : undefined;
|
|
715
|
+
return {
|
|
716
|
+
ok: true,
|
|
717
|
+
request_id: raw.request_id,
|
|
718
|
+
data: message !== undefined ? { message } : {},
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* POST /digital-assets/dashboard/generate-share-assets-url
|
|
723
|
+
*
|
|
724
|
+
* Source: Admin-Frontend
|
|
725
|
+
* `components/dam/Dialogs/ShareAssetDialog/index.vue:399`. Body shape:
|
|
726
|
+
*
|
|
727
|
+
* { workspace_id, assets: number[], category: number[], title?, description?,
|
|
728
|
+
* password?, hide_download?: 0|1, expiration?: 'MMM D, YYYY' }
|
|
729
|
+
*
|
|
730
|
+
* Returns the generated share link projection
|
|
731
|
+
* (`{ data: { id, share_url, ... } }`).
|
|
732
|
+
*/
|
|
733
|
+
async generateShareAssetsUrl(input) {
|
|
734
|
+
const body = {
|
|
735
|
+
workspace_id: this.workspaceId,
|
|
736
|
+
assets: input.assets,
|
|
737
|
+
category: input.category,
|
|
738
|
+
title: input.title ?? '',
|
|
739
|
+
description: input.description ?? null,
|
|
740
|
+
};
|
|
741
|
+
if (input.password !== undefined)
|
|
742
|
+
body['password'] = input.password;
|
|
743
|
+
if (input.hide_download !== undefined)
|
|
744
|
+
body['hide_download'] = input.hide_download;
|
|
745
|
+
if (input.expiration !== undefined)
|
|
746
|
+
body['expiration'] = input.expiration;
|
|
747
|
+
const raw = await this.post('digital-assets/dashboard/generate-share-assets-url', { body });
|
|
748
|
+
if (!raw.ok)
|
|
749
|
+
return raw;
|
|
750
|
+
try {
|
|
751
|
+
const parsed = unwrapEnvelope(raw.data, GenerateShareLinkResponseSchema);
|
|
752
|
+
return { ok: true, data: parsed, request_id: raw.request_id };
|
|
753
|
+
}
|
|
754
|
+
catch (err) {
|
|
755
|
+
return {
|
|
756
|
+
ok: false,
|
|
757
|
+
request_id: raw.request_id,
|
|
758
|
+
error: createToolError('UPSTREAM', `Schema mismatch on generate-share-assets-url: ${err instanceof Error ? err.message : String(err)}`, { cause: { request_id: raw.request_id } }),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* POST /digital-assets/collection/generate-share-url
|
|
764
|
+
*
|
|
765
|
+
* Source: Admin-Frontend
|
|
766
|
+
* `components/dam/Dialogs/ShareAssetDialog/index.vue:520`. Body shape:
|
|
767
|
+
*
|
|
768
|
+
* { workspace_id, id (collection id), assets: number[], title?, description?,
|
|
769
|
+
* password?, hide_download?: 0|1, expiration?: 'MMM D, YYYY' }
|
|
770
|
+
*/
|
|
771
|
+
async generateCollectionShareUrl(input) {
|
|
772
|
+
const body = {
|
|
773
|
+
workspace_id: this.workspaceId,
|
|
774
|
+
id: input.collection_id,
|
|
775
|
+
assets: input.assets,
|
|
776
|
+
title: input.title ?? '',
|
|
777
|
+
description: input.description ?? null,
|
|
778
|
+
};
|
|
779
|
+
if (input.password !== undefined)
|
|
780
|
+
body['password'] = input.password;
|
|
781
|
+
if (input.hide_download !== undefined)
|
|
782
|
+
body['hide_download'] = input.hide_download;
|
|
783
|
+
if (input.expiration !== undefined)
|
|
784
|
+
body['expiration'] = input.expiration;
|
|
785
|
+
const raw = await this.post('digital-assets/collection/generate-share-url', { body });
|
|
786
|
+
if (!raw.ok)
|
|
787
|
+
return raw;
|
|
788
|
+
try {
|
|
789
|
+
const parsed = unwrapEnvelope(raw.data, GenerateShareLinkResponseSchema);
|
|
790
|
+
return { ok: true, data: parsed, request_id: raw.request_id };
|
|
791
|
+
}
|
|
792
|
+
catch (err) {
|
|
793
|
+
return {
|
|
794
|
+
ok: false,
|
|
795
|
+
request_id: raw.request_id,
|
|
796
|
+
error: createToolError('UPSTREAM', `Schema mismatch on collection/generate-share-url: ${err instanceof Error ? err.message : String(err)}`, { cause: { request_id: raw.request_id } }),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* GET /digital-assets/dashboard/list-share-assets-url
|
|
802
|
+
*
|
|
803
|
+
* Source: Admin-Frontend
|
|
804
|
+
* `pages/_workspace_id/dam/sharing/index.vue:784`. Query shape:
|
|
805
|
+
*
|
|
806
|
+
* { workspace_id, page, total_record, filter_by?, sort_value?, sort_by? }
|
|
807
|
+
*
|
|
808
|
+
* Returns a Laravel paginator under `data`. `filter_by` accepts
|
|
809
|
+
* Admin-Frontend values like `'active'`, `'expired'`, `'revoked'`.
|
|
810
|
+
*/
|
|
811
|
+
async listShareLinks(input) {
|
|
812
|
+
// `filter_by` is REQUIRED by upstream — omitting it returns HTTP 400
|
|
813
|
+
// "The filter by field is required." (verified live 2026-04-29).
|
|
814
|
+
// Admin-Frontend defaults to 'active'; mirror that. Callers can override.
|
|
815
|
+
const query = {
|
|
816
|
+
workspace_id: this.workspaceId,
|
|
817
|
+
page: input.page,
|
|
818
|
+
total_record: input.pageSize,
|
|
819
|
+
sort_by: input.sortBy ?? 'DESC',
|
|
820
|
+
sort_value: input.sortValue ?? 'created_at',
|
|
821
|
+
filter_by: input.filterBy ?? 'active',
|
|
822
|
+
};
|
|
823
|
+
const raw = await this.get('digital-assets/dashboard/list-share-assets-url', { query });
|
|
824
|
+
if (!raw.ok)
|
|
825
|
+
return raw;
|
|
826
|
+
try {
|
|
827
|
+
const parsed = unwrapEnvelope(raw.data, ShareLinkListEnvelopeSchema);
|
|
828
|
+
return { ok: true, data: parsed, request_id: raw.request_id };
|
|
829
|
+
}
|
|
830
|
+
catch (err) {
|
|
831
|
+
return {
|
|
832
|
+
ok: false,
|
|
833
|
+
request_id: raw.request_id,
|
|
834
|
+
error: createToolError('UPSTREAM', `Schema mismatch on list-share-assets-url: ${err instanceof Error ? err.message : String(err)}`, { cause: { request_id: raw.request_id } }),
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Read a single share link by id (client-side filter over
|
|
840
|
+
* `listShareLinks`). Used for the revoke preview so the dry-run plan
|
|
841
|
+
* can show `before` state.
|
|
842
|
+
*/
|
|
843
|
+
async getShareLink(shareId) {
|
|
844
|
+
const list = await this.listShareLinks({ page: 1, pageSize: 200 });
|
|
845
|
+
if (!list.ok)
|
|
846
|
+
return list;
|
|
847
|
+
const target = String(shareId);
|
|
848
|
+
const match = list.data.data.find((row) => String(row.id) === target);
|
|
849
|
+
if (match === undefined) {
|
|
850
|
+
return {
|
|
851
|
+
ok: false,
|
|
852
|
+
request_id: list.request_id,
|
|
853
|
+
error: createToolError('NOT_FOUND', `Share link ${shareId} not found in workspace.`, { cause: { request_id: list.request_id } }),
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
return { ok: true, data: match, request_id: list.request_id };
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* POST /digital-assets/dashboard/delete-share-assets-url
|
|
860
|
+
*
|
|
861
|
+
* Source: Admin-Frontend
|
|
862
|
+
* `pages/_workspace_id/dam/sharing/index.vue:1059`. Body shape:
|
|
863
|
+
*
|
|
864
|
+
* { workspace_id, share_url_id: number[] }
|
|
865
|
+
*
|
|
866
|
+
* The Admin-Frontend reuses this endpoint for both single-id and
|
|
867
|
+
* `revoke-all` flows; only the multi-id bulk endpoint
|
|
868
|
+
* (`delete-multiple-share-assets-url`) uses `share_url_ids`. We use
|
|
869
|
+
* the single-id path because `revoke_share_link` is a one-at-a-time tool.
|
|
870
|
+
*/
|
|
871
|
+
async revokeShareLink(shareId) {
|
|
872
|
+
const raw = await this.post('digital-assets/dashboard/delete-share-assets-url', {
|
|
873
|
+
body: {
|
|
874
|
+
workspace_id: this.workspaceId,
|
|
875
|
+
share_url_id: [shareId],
|
|
876
|
+
},
|
|
877
|
+
});
|
|
878
|
+
if (!raw.ok)
|
|
879
|
+
return raw;
|
|
880
|
+
const parsed = SimpleMessageEnvelopeSchema.safeParse(raw.data);
|
|
881
|
+
const message = parsed.success ? parsed.data.message : undefined;
|
|
882
|
+
return {
|
|
883
|
+
ok: true,
|
|
884
|
+
request_id: raw.request_id,
|
|
885
|
+
data: message !== undefined ? { message } : {},
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* GET /digital-assets/{id}/generate-embed-code
|
|
890
|
+
*
|
|
891
|
+
* Source: Admin-Frontend
|
|
892
|
+
* `components/dam/Dialogs/ShareAssetDialog/index.vue:476`. Read-only
|
|
893
|
+
* (the upstream caches and returns a stable embed snippet for the asset).
|
|
894
|
+
* Response shape: `{ data: { embed_code: '<iframe...>' } }`.
|
|
895
|
+
*/
|
|
896
|
+
async generateEmbedCode(assetId) {
|
|
897
|
+
const raw = await this.get(`digital-assets/${encodeURIComponent(String(assetId))}/generate-embed-code`);
|
|
898
|
+
if (!raw.ok)
|
|
899
|
+
return raw;
|
|
900
|
+
try {
|
|
901
|
+
const parsed = unwrapEnvelope(raw.data, GenerateEmbedCodeResponseSchema);
|
|
902
|
+
return { ok: true, data: parsed, request_id: raw.request_id };
|
|
903
|
+
}
|
|
904
|
+
catch (err) {
|
|
905
|
+
return {
|
|
906
|
+
ok: false,
|
|
907
|
+
request_id: raw.request_id,
|
|
908
|
+
error: createToolError('UPSTREAM', `Schema mismatch on generate-embed-code: ${err instanceof Error ? err.message : String(err)}`, { cause: { request_id: raw.request_id } }),
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Internal helper: GET + zod-parse using `unwrapEnvelope` so callers can
|
|
914
|
+
* pass either the bare-array shape or `{ status, message, data: [...] }`.
|
|
915
|
+
*/
|
|
916
|
+
async parsedGet(path, schema, options) {
|
|
917
|
+
const raw = await this.get(path, options);
|
|
918
|
+
if (!raw.ok)
|
|
919
|
+
return raw;
|
|
920
|
+
try {
|
|
921
|
+
const parsed = unwrapEnvelope(raw.data, schema);
|
|
922
|
+
return { ok: true, data: parsed, request_id: raw.request_id };
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
return {
|
|
926
|
+
ok: false,
|
|
927
|
+
request_id: raw.request_id,
|
|
928
|
+
error: createToolError('UPSTREAM', `Schema mismatch on ${path}: ${err instanceof Error ? err.message : String(err)}`, { cause: { request_id: raw.request_id } }),
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
buildUrl(path, options) {
|
|
933
|
+
const cleanedPath = path.replace(/^\/+/, '');
|
|
934
|
+
const url = new URL(cleanedPath, this.baseUrl);
|
|
935
|
+
if (options?.scoped !== false) {
|
|
936
|
+
url.searchParams.set('url_workspace_id', this.workspaceId);
|
|
937
|
+
}
|
|
938
|
+
if (options?.query) {
|
|
939
|
+
for (const [key, value] of Object.entries(options.query)) {
|
|
940
|
+
if (value !== undefined) {
|
|
941
|
+
url.searchParams.set(key, String(value));
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return url.toString();
|
|
946
|
+
}
|
|
947
|
+
async request(method, path, options) {
|
|
948
|
+
const requestId = generateRequestId();
|
|
949
|
+
const url = this.buildUrl(path, options);
|
|
950
|
+
const childLog = this.log.child({ request_id: requestId, method, path });
|
|
951
|
+
const headers = {
|
|
952
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
953
|
+
Accept: 'application/json',
|
|
954
|
+
'X-Request-Id': requestId,
|
|
955
|
+
};
|
|
956
|
+
if (options?.body !== undefined) {
|
|
957
|
+
headers['Content-Type'] = 'application/json';
|
|
958
|
+
}
|
|
959
|
+
const fetchOnce = async () => {
|
|
960
|
+
await this.limiter.acquire();
|
|
961
|
+
const controller = new AbortController();
|
|
962
|
+
const timeout = setTimeout(() => controller.abort(), options?.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
963
|
+
try {
|
|
964
|
+
const init = {
|
|
965
|
+
method,
|
|
966
|
+
headers,
|
|
967
|
+
signal: controller.signal,
|
|
968
|
+
};
|
|
969
|
+
if (options?.body !== undefined) {
|
|
970
|
+
init.body = JSON.stringify(options.body);
|
|
971
|
+
}
|
|
972
|
+
const response = await fetch(url, init);
|
|
973
|
+
if (!response.ok) {
|
|
974
|
+
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
|
|
975
|
+
const text = sanitizeUpstreamBody(await safeReadText(response));
|
|
976
|
+
if (response.status === 429 || (response.status >= 500 && response.status < 600)) {
|
|
977
|
+
const opts = {
|
|
978
|
+
statusCode: response.status,
|
|
979
|
+
};
|
|
980
|
+
if (retryAfterMs !== undefined) {
|
|
981
|
+
opts.retryAfterMs = retryAfterMs;
|
|
982
|
+
}
|
|
983
|
+
throw new RateLimitedError(`Upstream ${response.status}: ${text}`, opts);
|
|
984
|
+
}
|
|
985
|
+
// Non-retriable HTTP error — wrap with the status so the outer
|
|
986
|
+
// handler can map it to an MCP error code.
|
|
987
|
+
throw new HttpError(response.status, text);
|
|
988
|
+
}
|
|
989
|
+
if (response.status === 204) {
|
|
990
|
+
return undefined;
|
|
991
|
+
}
|
|
992
|
+
const contentType = response.headers.get('Content-Type') ?? '';
|
|
993
|
+
if (!contentType.includes('application/json')) {
|
|
994
|
+
// Non-JSON success bodies are surfaced as text.
|
|
995
|
+
return (await response.text());
|
|
996
|
+
}
|
|
997
|
+
return (await response.json());
|
|
998
|
+
}
|
|
999
|
+
finally {
|
|
1000
|
+
clearTimeout(timeout);
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
try {
|
|
1004
|
+
const data = await withRetry(fetchOnce);
|
|
1005
|
+
childLog.debug('collage request ok');
|
|
1006
|
+
return { ok: true, data, request_id: requestId };
|
|
1007
|
+
}
|
|
1008
|
+
catch (err) {
|
|
1009
|
+
const error = mapToToolError(err, requestId);
|
|
1010
|
+
childLog.warn({ err: serialiseErr(err), code: error.code }, 'collage request failed');
|
|
1011
|
+
return { ok: false, error, request_id: requestId };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
class HttpError extends Error {
|
|
1016
|
+
status;
|
|
1017
|
+
body;
|
|
1018
|
+
constructor(status, body) {
|
|
1019
|
+
super(`HTTP ${status}: ${body}`);
|
|
1020
|
+
this.status = status;
|
|
1021
|
+
this.body = body;
|
|
1022
|
+
this.name = 'HttpError';
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
function parseRetryAfter(header) {
|
|
1026
|
+
if (header === null || header === '')
|
|
1027
|
+
return undefined;
|
|
1028
|
+
const seconds = Number(header);
|
|
1029
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
1030
|
+
return seconds * 1000;
|
|
1031
|
+
}
|
|
1032
|
+
const date = Date.parse(header);
|
|
1033
|
+
if (!Number.isNaN(date)) {
|
|
1034
|
+
const ms = date - Date.now();
|
|
1035
|
+
return ms > 0 ? ms : 0;
|
|
1036
|
+
}
|
|
1037
|
+
return undefined;
|
|
1038
|
+
}
|
|
1039
|
+
async function safeReadText(response) {
|
|
1040
|
+
try {
|
|
1041
|
+
return await response.text();
|
|
1042
|
+
}
|
|
1043
|
+
catch {
|
|
1044
|
+
return '';
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const UPSTREAM_BODY_MAX_CHARS = 512;
|
|
1048
|
+
/**
|
|
1049
|
+
* Strip credential-shaped substrings and clamp length before an upstream
|
|
1050
|
+
* body is interpolated into an `McpToolError.message` (which is visible
|
|
1051
|
+
* to the LLM client). Defensive — production Collage responses do not
|
|
1052
|
+
* echo credentials, but custom middleware or debug builds could.
|
|
1053
|
+
*
|
|
1054
|
+
* The `X-Amz-Signature=` redaction handles presigned S3 URLs returned
|
|
1055
|
+
* in `view-detail` responses. A presigned URL with a 24h expiry is a
|
|
1056
|
+
* bearer-equivalent for the asset until it expires; leaking the
|
|
1057
|
+
* signature into a logged error or LLM-visible message is the same
|
|
1058
|
+
* risk class as leaking a token.
|
|
1059
|
+
*/
|
|
1060
|
+
function sanitizeUpstreamBody(text) {
|
|
1061
|
+
if (text === '')
|
|
1062
|
+
return text;
|
|
1063
|
+
const scrubbed = text
|
|
1064
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [REDACTED]')
|
|
1065
|
+
.replace(/eyJ[A-Za-z0-9._-]{20,}/g, '[REDACTED_JWT]')
|
|
1066
|
+
.replace(/(api[_-]?key|token|secret)\s*[:=]\s*"?[A-Za-z0-9._-]{8,}"?/gi, '$1=[REDACTED]')
|
|
1067
|
+
.replace(/X-Amz-Signature=[^&\s"'<>]+/gi, 'X-Amz-Signature=[REDACTED]');
|
|
1068
|
+
return scrubbed.length > UPSTREAM_BODY_MAX_CHARS
|
|
1069
|
+
? `${scrubbed.slice(0, UPSTREAM_BODY_MAX_CHARS)}…`
|
|
1070
|
+
: scrubbed;
|
|
1071
|
+
}
|
|
1072
|
+
function mapToToolError(err, requestId) {
|
|
1073
|
+
if (err instanceof HttpError) {
|
|
1074
|
+
return createToolError(mapHttpStatusToErrorCode(err.status), err.message, {
|
|
1075
|
+
cause: { upstream_status: err.status, request_id: requestId },
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
if (err instanceof RateLimitedError) {
|
|
1079
|
+
return createToolError(mapHttpStatusToErrorCode(err.statusCode), err.message, {
|
|
1080
|
+
cause: { upstream_status: err.statusCode, request_id: requestId },
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
1084
|
+
return createToolError('UPSTREAM', 'Request timed out', {
|
|
1085
|
+
cause: { request_id: requestId },
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1089
|
+
return createToolError('INTERNAL', message, { cause: { request_id: requestId } });
|
|
1090
|
+
}
|
|
1091
|
+
function serialiseErr(err) {
|
|
1092
|
+
if (err instanceof Error) {
|
|
1093
|
+
return { name: err.name, message: err.message };
|
|
1094
|
+
}
|
|
1095
|
+
return { value: String(err) };
|
|
1096
|
+
}
|
|
1097
|
+
// ── Collection-mutation response helpers ─────────────────────────────
|
|
1098
|
+
// The collection write endpoints return either an envelope
|
|
1099
|
+
// (`{ status, message, data: { id, name, description, ... } }`) or — on some
|
|
1100
|
+
// older routes — the row directly. We extract the canonical fields
|
|
1101
|
+
// defensively so a missing `description` is normalised to `null`.
|
|
1102
|
+
const CollectionMutationDataSchema = z.object({
|
|
1103
|
+
id: z.union([z.number(), z.string()]).optional(),
|
|
1104
|
+
name: z.string().optional(),
|
|
1105
|
+
description: z.string().nullable().optional(),
|
|
1106
|
+
}).passthrough();
|
|
1107
|
+
function parseCollectionMutationResponse(raw, fallbackId) {
|
|
1108
|
+
try {
|
|
1109
|
+
const data = unwrapEnvelope(raw.data, CollectionMutationDataSchema);
|
|
1110
|
+
const id = data.id ?? fallbackId;
|
|
1111
|
+
if (id === undefined) {
|
|
1112
|
+
throw new Error('response omitted id and no fallback was provided');
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
ok: true,
|
|
1116
|
+
request_id: raw.request_id,
|
|
1117
|
+
data: {
|
|
1118
|
+
id,
|
|
1119
|
+
name: data.name ?? '',
|
|
1120
|
+
description: data.description ?? null,
|
|
1121
|
+
},
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
catch (err) {
|
|
1125
|
+
return {
|
|
1126
|
+
ok: false,
|
|
1127
|
+
request_id: raw.request_id,
|
|
1128
|
+
error: createToolError('UPSTREAM', `Schema mismatch on collection mutation: ${err instanceof Error ? err.message : String(err)}`, { cause: { request_id: raw.request_id } }),
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
function extractMessage(raw) {
|
|
1133
|
+
if (raw !== null && typeof raw === 'object' && 'message' in raw) {
|
|
1134
|
+
const msg = raw.message;
|
|
1135
|
+
if (typeof msg === 'string')
|
|
1136
|
+
return msg;
|
|
1137
|
+
}
|
|
1138
|
+
return undefined;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Pulls the new folder id out of a `category/add` or
|
|
1142
|
+
* `category/add-sub-category` response. Tolerates both top-level `id` and
|
|
1143
|
+
* the more common `data.id` shape.
|
|
1144
|
+
*/
|
|
1145
|
+
function extractCreatedCategoryId(raw) {
|
|
1146
|
+
if (raw === null || typeof raw !== 'object')
|
|
1147
|
+
return null;
|
|
1148
|
+
const obj = raw;
|
|
1149
|
+
const candidates = [obj['id']];
|
|
1150
|
+
const data = obj['data'];
|
|
1151
|
+
if (data !== null && typeof data === 'object') {
|
|
1152
|
+
candidates.push(data['id']);
|
|
1153
|
+
}
|
|
1154
|
+
for (const c of candidates) {
|
|
1155
|
+
if (typeof c === 'number' && Number.isFinite(c))
|
|
1156
|
+
return c;
|
|
1157
|
+
if (typeof c === 'string' && c.length > 0)
|
|
1158
|
+
return c;
|
|
1159
|
+
}
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
//# sourceMappingURL=client.js.map
|