@labelgrid/core 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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/api/content-types.d.ts +33 -0
- package/dist/api/content-types.js +87 -0
- package/dist/api/http.d.ts +77 -0
- package/dist/api/http.js +394 -0
- package/dist/api/upload.d.ts +32 -0
- package/dist/api/upload.js +104 -0
- package/dist/entities.d.ts +25 -0
- package/dist/entities.js +51 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/log.d.ts +16 -0
- package/dist/log.js +35 -0
- package/package.json +30 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@labelgrid/core` are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
`@labelgrid/core` is primarily an internal shared client for the LabelGrid MCP
|
|
9
|
+
server and CLI — there are no API-stability promises before 1.0.
|
|
10
|
+
|
|
11
|
+
## [0.1.0] - 2026-07-20
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Initial release, extracted from the `@labelgrid/mcp` server: the LabelGrid
|
|
16
|
+
public-API HTTP transport with structured errors and timeouts, presigned-URL
|
|
17
|
+
and multipart upload flows with extension allowlists and symlink resolution,
|
|
18
|
+
upload content-type resolution, the catalog-entity registry, and stderr-only
|
|
19
|
+
logging with secret redaction.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LabelGrid
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @labelgrid/core
|
|
2
|
+
|
|
3
|
+
`@labelgrid/core` is the shared client library for the [LabelGrid](https://labelgrid.com) public API. It provides one surface for everything a LabelGrid tool needs to talk to the API: an HTTP transport that normalizes every failure into a structured error (never a thrown protocol exception), presigned-URL and multipart upload flows with file-extension allowlists and symlink resolution, upload content-type resolution, the catalog-entity registry (labels, artists, writers, publishers, releases, tracks and their endpoint paths), and stderr-only logging with secret redaction.
|
|
4
|
+
|
|
5
|
+
It is **primarily an internal package**: its reason to exist is to be the single implementation shared by the [`@labelgrid/mcp`](https://www.npmjs.com/package/@labelgrid/mcp) server and the [`@labelgrid/cli`](./../cli) command-line tool, which are both thin adapters over this client. It is published to npm so those packages can declare it as a regular dependency — not as a standalone, general-purpose SDK. If you are integrating with LabelGrid yourself, the [public API documentation](https://help.labelgrid.com/en/integrations/api-overview) is the supported surface.
|
|
6
|
+
|
|
7
|
+
Because of that, **there are no API-stability promises before 1.0**: minor releases may rename, reshape, or remove exports as the MCP server and CLI evolve. Pin an exact version if you depend on it directly, and expect to read the changelog when upgrading.
|
|
8
|
+
|
|
9
|
+
## License
|
|
10
|
+
|
|
11
|
+
[MIT](./LICENSE) © LabelGrid
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared file content-type inference and the upload extension allow-list guard.
|
|
3
|
+
*
|
|
4
|
+
* CONTENT_TYPES (moved here from upload.ts) infers a best-effort MIME type from
|
|
5
|
+
* a file extension — used for the presigned PUT and the multipart Blob.
|
|
6
|
+
* assertAllowedExtension is the per-tool guard: each file-accepting tool
|
|
7
|
+
* declares exactly which extensions it accepts, and the guard rejects anything
|
|
8
|
+
* else BEFORE the file is read or any HTTP call is made, so an upload tool can
|
|
9
|
+
* never be pointed at an arbitrary local file.
|
|
10
|
+
*/
|
|
11
|
+
import type { ApiError } from './http.js';
|
|
12
|
+
/** Best-effort Content-Type inferred from a file extension. */
|
|
13
|
+
export declare const CONTENT_TYPES: Record<string, string>;
|
|
14
|
+
/** Best-effort Content-Type for a file path (default application/octet-stream). */
|
|
15
|
+
export declare function contentType(filePath: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Rejects a file whose extension is not in `allowed` (case-insensitive), before
|
|
18
|
+
* any read or HTTP call, and resolves the path to its real target. The supplied
|
|
19
|
+
* path's extension is checked first (the fast path); then the path is resolved
|
|
20
|
+
* with realpathSync and the REAL target's extension is checked too, so a symlink
|
|
21
|
+
* named `cover.jpg` that points at an arbitrary local file cannot slip past the
|
|
22
|
+
* guard. On success it returns `{ realPath }` — the resolved canonical path,
|
|
23
|
+
* which the caller MUST use as the path it reads/uploads (never the original
|
|
24
|
+
* argument), so a symlink retargeted after validation cannot redirect the read
|
|
25
|
+
* (the resolved target is what gets uploaded). On failure it returns `{ error }`
|
|
26
|
+
* — a structured FILE_TYPE_NOT_ALLOWED, or FILE_NOT_FOUND if the path does not
|
|
27
|
+
* resolve.
|
|
28
|
+
*/
|
|
29
|
+
export declare function assertAllowedExtension(filePath: string, allowed: string[]): {
|
|
30
|
+
error: ApiError;
|
|
31
|
+
} | {
|
|
32
|
+
realPath: string;
|
|
33
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared file content-type inference and the upload extension allow-list guard.
|
|
3
|
+
*
|
|
4
|
+
* CONTENT_TYPES (moved here from upload.ts) infers a best-effort MIME type from
|
|
5
|
+
* a file extension — used for the presigned PUT and the multipart Blob.
|
|
6
|
+
* assertAllowedExtension is the per-tool guard: each file-accepting tool
|
|
7
|
+
* declares exactly which extensions it accepts, and the guard rejects anything
|
|
8
|
+
* else BEFORE the file is read or any HTTP call is made, so an upload tool can
|
|
9
|
+
* never be pointed at an arbitrary local file.
|
|
10
|
+
*/
|
|
11
|
+
import { realpathSync } from 'node:fs';
|
|
12
|
+
import { extname } from 'node:path';
|
|
13
|
+
/** Best-effort Content-Type inferred from a file extension. */
|
|
14
|
+
export const CONTENT_TYPES = {
|
|
15
|
+
'.wav': 'audio/wav',
|
|
16
|
+
'.flac': 'audio/flac',
|
|
17
|
+
'.aif': 'audio/aiff',
|
|
18
|
+
'.aiff': 'audio/aiff',
|
|
19
|
+
'.mp3': 'audio/mpeg',
|
|
20
|
+
'.lrc': 'text/plain',
|
|
21
|
+
'.txt': 'text/plain',
|
|
22
|
+
'.jpg': 'image/jpeg',
|
|
23
|
+
'.jpeg': 'image/jpeg',
|
|
24
|
+
'.png': 'image/png',
|
|
25
|
+
'.webp': 'image/webp',
|
|
26
|
+
'.tif': 'image/tiff',
|
|
27
|
+
'.tiff': 'image/tiff',
|
|
28
|
+
'.pdf': 'application/pdf',
|
|
29
|
+
'.mp4': 'video/mp4',
|
|
30
|
+
'.mov': 'video/quicktime',
|
|
31
|
+
};
|
|
32
|
+
/** Best-effort Content-Type for a file path (default application/octet-stream). */
|
|
33
|
+
export function contentType(filePath) {
|
|
34
|
+
return CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Rejects a file whose extension is not in `allowed` (case-insensitive), before
|
|
38
|
+
* any read or HTTP call, and resolves the path to its real target. The supplied
|
|
39
|
+
* path's extension is checked first (the fast path); then the path is resolved
|
|
40
|
+
* with realpathSync and the REAL target's extension is checked too, so a symlink
|
|
41
|
+
* named `cover.jpg` that points at an arbitrary local file cannot slip past the
|
|
42
|
+
* guard. On success it returns `{ realPath }` — the resolved canonical path,
|
|
43
|
+
* which the caller MUST use as the path it reads/uploads (never the original
|
|
44
|
+
* argument), so a symlink retargeted after validation cannot redirect the read
|
|
45
|
+
* (the resolved target is what gets uploaded). On failure it returns `{ error }`
|
|
46
|
+
* — a structured FILE_TYPE_NOT_ALLOWED, or FILE_NOT_FOUND if the path does not
|
|
47
|
+
* resolve.
|
|
48
|
+
*/
|
|
49
|
+
export function assertAllowedExtension(filePath, allowed) {
|
|
50
|
+
const isAllowed = (candidate) => allowed.some((a) => a.toLowerCase() === candidate);
|
|
51
|
+
const ext = extname(filePath).toLowerCase();
|
|
52
|
+
// Fast path: reject a plainly-disallowed extension before touching the disk.
|
|
53
|
+
if (!isAllowed(ext)) {
|
|
54
|
+
return {
|
|
55
|
+
error: {
|
|
56
|
+
code: 'FILE_TYPE_NOT_ALLOWED',
|
|
57
|
+
message: `This tool only accepts ${allowed.join(', ')} files (got "${ext || 'no extension'}").`,
|
|
58
|
+
status: 0,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// The supplied name is allowed; resolve symlinks and re-check the real target.
|
|
63
|
+
let realPath;
|
|
64
|
+
try {
|
|
65
|
+
realPath = realpathSync(filePath);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return {
|
|
69
|
+
error: {
|
|
70
|
+
code: 'FILE_NOT_FOUND',
|
|
71
|
+
message: `No readable file at ${filePath}.`,
|
|
72
|
+
status: 0,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const realExt = extname(realPath).toLowerCase();
|
|
77
|
+
if (!isAllowed(realExt)) {
|
|
78
|
+
return {
|
|
79
|
+
error: {
|
|
80
|
+
code: 'FILE_TYPE_NOT_ALLOWED',
|
|
81
|
+
message: `The file resolves to a "${realExt || 'no extension'}" file; this tool only accepts ${allowed.join(', ')}.`,
|
|
82
|
+
status: 0,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { realPath };
|
|
87
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single typed HTTP client for the LabelGrid public API.
|
|
3
|
+
*
|
|
4
|
+
* Every tool goes through this client. It owns transport, header injection,
|
|
5
|
+
* query serialization, optional idempotency keys and — critically — error
|
|
6
|
+
* normalization: HTTP failures are turned into a structured {@link ApiError}
|
|
7
|
+
* and returned, never thrown. Business rules live server-side; this file is
|
|
8
|
+
* transport only (no retries, no queues).
|
|
9
|
+
*/
|
|
10
|
+
export type ApiError = {
|
|
11
|
+
code: string;
|
|
12
|
+
message: string;
|
|
13
|
+
status: number;
|
|
14
|
+
field?: string;
|
|
15
|
+
suggestion?: string;
|
|
16
|
+
retry_after_seconds?: number;
|
|
17
|
+
errors?: unknown;
|
|
18
|
+
/** Structured validation detail passed through verbatim from the API (422). */
|
|
19
|
+
errors_structured?: unknown;
|
|
20
|
+
};
|
|
21
|
+
export type ApiResult<T = unknown> = {
|
|
22
|
+
data: T;
|
|
23
|
+
} | {
|
|
24
|
+
error: ApiError;
|
|
25
|
+
};
|
|
26
|
+
export declare class LabelGridClient {
|
|
27
|
+
private readonly baseUrl;
|
|
28
|
+
private readonly token;
|
|
29
|
+
private readonly fetchFn;
|
|
30
|
+
private readonly version;
|
|
31
|
+
private readonly userAgent;
|
|
32
|
+
private readonly timeoutMs;
|
|
33
|
+
private readonly rawTimeoutMs;
|
|
34
|
+
constructor(opts: {
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
token: string;
|
|
37
|
+
fetchFn?: typeof fetch;
|
|
38
|
+
version: string;
|
|
39
|
+
/** Full User-Agent string; defaults to `labelgrid-mcp/<version>`. */
|
|
40
|
+
userAgent?: string;
|
|
41
|
+
/** API request timeout (default 60s) — a hung call must never hang a tool. */
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
/** Timeout for raw transfers like presigned uploads (default 10min). */
|
|
44
|
+
rawTimeoutMs?: number;
|
|
45
|
+
});
|
|
46
|
+
private authHeaders;
|
|
47
|
+
private send;
|
|
48
|
+
/**
|
|
49
|
+
* Reads a response body with the byte ceiling enforced mid-stream. Returns
|
|
50
|
+
* the decoded text, or the supplied too-large error result when the ceiling
|
|
51
|
+
* is crossed. Abort/timeout rejections propagate to the caller for mapping.
|
|
52
|
+
*/
|
|
53
|
+
private readBody;
|
|
54
|
+
get<T>(path: string, query?: Record<string, unknown>): Promise<ApiResult<T>>;
|
|
55
|
+
post<T>(path: string, body?: unknown, opts?: {
|
|
56
|
+
idempotency?: boolean;
|
|
57
|
+
idempotencyKey?: string;
|
|
58
|
+
}): Promise<ApiResult<T>>;
|
|
59
|
+
patch<T>(path: string, body?: unknown): Promise<ApiResult<T>>;
|
|
60
|
+
put<T>(path: string, body?: unknown, opts?: {
|
|
61
|
+
idempotency?: boolean;
|
|
62
|
+
idempotencyKey?: string;
|
|
63
|
+
}): Promise<ApiResult<T>>;
|
|
64
|
+
delete<T>(path: string): Promise<ApiResult<T>>;
|
|
65
|
+
/**
|
|
66
|
+
* Sends a multipart/form-data POST with a single file field plus optional
|
|
67
|
+
* extra string fields. A missing/unreadable file yields a FILE_NOT_FOUND
|
|
68
|
+
* error result rather than throwing.
|
|
69
|
+
*/
|
|
70
|
+
postMultipart<T>(path: string, filePath: string, fieldName: string, extra?: Record<string, string>): Promise<ApiResult<T>>;
|
|
71
|
+
/**
|
|
72
|
+
* Performs a raw request with NO Authorization header — used for presigned
|
|
73
|
+
* upload PUTs, where the signed URL is already the credential and an extra
|
|
74
|
+
* Bearer token would break the signature.
|
|
75
|
+
*/
|
|
76
|
+
raw(url: string, init: RequestInit): Promise<Response>;
|
|
77
|
+
}
|
package/dist/api/http.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single typed HTTP client for the LabelGrid public API.
|
|
3
|
+
*
|
|
4
|
+
* Every tool goes through this client. It owns transport, header injection,
|
|
5
|
+
* query serialization, optional idempotency keys and — critically — error
|
|
6
|
+
* normalization: HTTP failures are turned into a structured {@link ApiError}
|
|
7
|
+
* and returned, never thrown. Business rules live server-side; this file is
|
|
8
|
+
* transport only (no retries, no queues).
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
12
|
+
import { basename } from 'node:path';
|
|
13
|
+
import { contentType } from './content-types.js';
|
|
14
|
+
/** Hard ceiling on a single response body, in bytes/characters. */
|
|
15
|
+
const MAX_RESPONSE_BYTES = 10_000_000;
|
|
16
|
+
const TOKEN_SUGGESTION = 'Check LABELGRID_API_TOKEN — create a new token in your dashboard under Profile → API Tokens.';
|
|
17
|
+
/**
|
|
18
|
+
* Serializes a query object into a URL search string, supporting nested
|
|
19
|
+
* `filter[label_id]=5` objects and repeated `metrics[]=a&metrics[]=b` arrays.
|
|
20
|
+
* Null/undefined values are skipped. Bracket structure is kept literal; only
|
|
21
|
+
* key names and values are percent-encoded.
|
|
22
|
+
*/
|
|
23
|
+
function buildQuery(query) {
|
|
24
|
+
if (!query)
|
|
25
|
+
return '';
|
|
26
|
+
const parts = [];
|
|
27
|
+
const push = (rawKey, value) => {
|
|
28
|
+
if (value === undefined || value === null)
|
|
29
|
+
return;
|
|
30
|
+
parts.push(`${rawKey}=${encodeURIComponent(String(value))}`);
|
|
31
|
+
};
|
|
32
|
+
for (const [key, value] of Object.entries(query)) {
|
|
33
|
+
if (value === undefined || value === null)
|
|
34
|
+
continue;
|
|
35
|
+
const ek = encodeURIComponent(key);
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
for (const item of value)
|
|
38
|
+
push(`${ek}[]`, item);
|
|
39
|
+
}
|
|
40
|
+
else if (typeof value === 'object') {
|
|
41
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
42
|
+
const esk = encodeURIComponent(subKey);
|
|
43
|
+
if (Array.isArray(subValue)) {
|
|
44
|
+
for (const item of subValue)
|
|
45
|
+
push(`${ek}[${esk}][]`, item);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
push(`${ek}[${esk}]`, subValue);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
push(ek, value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return parts.length > 0 ? `?${parts.join('&')}` : '';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Extracts a code/message/errors triple from any of the four backend error body
|
|
60
|
+
* shapes: `{message}`, `{error: string}`, `{errors}`, `{error: {code, message}}`.
|
|
61
|
+
*/
|
|
62
|
+
function extractServerError(body) {
|
|
63
|
+
if (typeof body === 'string') {
|
|
64
|
+
return { message: body };
|
|
65
|
+
}
|
|
66
|
+
if (body === null || typeof body !== 'object') {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
const record = body;
|
|
70
|
+
const errors = record.errors;
|
|
71
|
+
const errorsStructured = record.errors_structured;
|
|
72
|
+
// Shape: { error: { code, message } }
|
|
73
|
+
if (record.error !== null && typeof record.error === 'object') {
|
|
74
|
+
const nested = record.error;
|
|
75
|
+
return {
|
|
76
|
+
code: typeof nested.code === 'string' ? nested.code : undefined,
|
|
77
|
+
message: typeof nested.message === 'string'
|
|
78
|
+
? nested.message
|
|
79
|
+
: typeof nested.error === 'string'
|
|
80
|
+
? nested.error
|
|
81
|
+
: undefined,
|
|
82
|
+
errors,
|
|
83
|
+
errors_structured: errorsStructured,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// Shape: { error: 'string' }
|
|
87
|
+
if (typeof record.error === 'string') {
|
|
88
|
+
return {
|
|
89
|
+
code: typeof record.code === 'string' ? record.code : undefined,
|
|
90
|
+
message: record.error,
|
|
91
|
+
errors,
|
|
92
|
+
errors_structured: errorsStructured,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// Shapes: { message } and/or { errors } and/or top-level { code }
|
|
96
|
+
const parts = {
|
|
97
|
+
code: typeof record.code === 'string' ? record.code : undefined,
|
|
98
|
+
message: typeof record.message === 'string' ? record.message : undefined,
|
|
99
|
+
field: typeof record.field === 'string' ? record.field : undefined,
|
|
100
|
+
errors,
|
|
101
|
+
errors_structured: errorsStructured,
|
|
102
|
+
};
|
|
103
|
+
// Derive a message from the first validation error when none was given.
|
|
104
|
+
if (parts.message === undefined && errors !== null && typeof errors === 'object') {
|
|
105
|
+
const first = Object.values(errors)[0];
|
|
106
|
+
if (Array.isArray(first) && typeof first[0] === 'string') {
|
|
107
|
+
parts.message = first[0];
|
|
108
|
+
}
|
|
109
|
+
else if (typeof first === 'string') {
|
|
110
|
+
parts.message = first;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return parts;
|
|
114
|
+
}
|
|
115
|
+
function parseRetryAfter(res) {
|
|
116
|
+
const raw = res.headers.get('Retry-After');
|
|
117
|
+
if (raw === null)
|
|
118
|
+
return undefined;
|
|
119
|
+
const seconds = Number.parseInt(raw, 10);
|
|
120
|
+
return Number.isNaN(seconds) ? undefined : seconds;
|
|
121
|
+
}
|
|
122
|
+
/** Normalizes a non-2xx HTTP response into a structured {@link ApiError}. */
|
|
123
|
+
function normalizeError(res, body) {
|
|
124
|
+
const server = extractServerError(body);
|
|
125
|
+
const status = res.status;
|
|
126
|
+
const withCommon = (code, message, extra = {}) => ({
|
|
127
|
+
code,
|
|
128
|
+
message,
|
|
129
|
+
status,
|
|
130
|
+
...(server.field !== undefined ? { field: server.field } : {}),
|
|
131
|
+
...(server.errors !== undefined ? { errors: server.errors } : {}),
|
|
132
|
+
...extra,
|
|
133
|
+
});
|
|
134
|
+
switch (status) {
|
|
135
|
+
case 401:
|
|
136
|
+
return withCommon('TOKEN_INVALID', server.message ?? 'Your API token was rejected.', {
|
|
137
|
+
suggestion: TOKEN_SUGGESTION,
|
|
138
|
+
});
|
|
139
|
+
case 403:
|
|
140
|
+
return withCommon(server.code ?? 'FORBIDDEN', server.message ?? 'Forbidden.');
|
|
141
|
+
case 404:
|
|
142
|
+
return withCommon('NOT_FOUND', server.message ?? 'The requested resource was not found.');
|
|
143
|
+
case 409:
|
|
144
|
+
return withCommon(server.code ?? 'CONFLICT', server.message ?? 'The request conflicts with the current state.');
|
|
145
|
+
case 422:
|
|
146
|
+
return withCommon('VALIDATION_FAILED', server.message ?? 'The submitted data was invalid.', {
|
|
147
|
+
...(server.errors_structured !== undefined
|
|
148
|
+
? { errors_structured: server.errors_structured }
|
|
149
|
+
: {}),
|
|
150
|
+
});
|
|
151
|
+
case 429: {
|
|
152
|
+
const retryAfter = parseRetryAfter(res);
|
|
153
|
+
return withCommon('RATE_LIMITED', server.message ?? 'Rate limit exceeded.', {
|
|
154
|
+
...(retryAfter !== undefined ? { retry_after_seconds: retryAfter } : {}),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
default:
|
|
158
|
+
if (status >= 500) {
|
|
159
|
+
return withCommon('SERVER_ERROR', server.message ?? 'The server encountered an error.');
|
|
160
|
+
}
|
|
161
|
+
return withCommon(server.code ?? 'ERROR', server.message ?? `Request failed with status ${status}.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export class LabelGridClient {
|
|
165
|
+
baseUrl;
|
|
166
|
+
token;
|
|
167
|
+
fetchFn;
|
|
168
|
+
version;
|
|
169
|
+
userAgent;
|
|
170
|
+
timeoutMs;
|
|
171
|
+
rawTimeoutMs;
|
|
172
|
+
constructor(opts) {
|
|
173
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
174
|
+
this.token = opts.token;
|
|
175
|
+
this.fetchFn = opts.fetchFn ?? fetch;
|
|
176
|
+
this.version = opts.version;
|
|
177
|
+
this.userAgent = opts.userAgent ?? `labelgrid-mcp/${opts.version}`;
|
|
178
|
+
this.timeoutMs = opts.timeoutMs ?? 60_000;
|
|
179
|
+
this.rawTimeoutMs = opts.rawTimeoutMs ?? 600_000;
|
|
180
|
+
}
|
|
181
|
+
authHeaders() {
|
|
182
|
+
return {
|
|
183
|
+
Authorization: `Bearer ${this.token}`,
|
|
184
|
+
Accept: 'application/json',
|
|
185
|
+
'User-Agent': this.userAgent,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
async send(method, path, opts = {}) {
|
|
189
|
+
const url = `${this.baseUrl}${path}${buildQuery(opts.query)}`;
|
|
190
|
+
const headers = { ...this.authHeaders(), ...opts.headers };
|
|
191
|
+
if (opts.idempotency) {
|
|
192
|
+
// A caller-supplied key is used verbatim (so a caller can dedupe a retry
|
|
193
|
+
// across separate tool calls); otherwise a fresh UUID is generated.
|
|
194
|
+
headers['Idempotency-Key'] = opts.idempotencyKey ?? randomUUID();
|
|
195
|
+
}
|
|
196
|
+
const init = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) };
|
|
197
|
+
if (opts.rawBody !== undefined) {
|
|
198
|
+
init.body = opts.rawBody;
|
|
199
|
+
}
|
|
200
|
+
else if (opts.body !== undefined) {
|
|
201
|
+
headers['Content-Type'] = 'application/json';
|
|
202
|
+
init.body = JSON.stringify(opts.body);
|
|
203
|
+
}
|
|
204
|
+
let res;
|
|
205
|
+
try {
|
|
206
|
+
res = await this.fetchFn(url, init);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
if (err instanceof DOMException &&
|
|
210
|
+
(err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
211
|
+
return {
|
|
212
|
+
error: {
|
|
213
|
+
code: 'TIMEOUT',
|
|
214
|
+
message: `The request timed out after ${Math.round(this.timeoutMs / 1000)} seconds. Try again, or narrow the request.`,
|
|
215
|
+
status: 0,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
error: {
|
|
221
|
+
code: 'NETWORK_ERROR',
|
|
222
|
+
message: err instanceof Error ? err.message : 'Network request failed.',
|
|
223
|
+
status: 0,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
// Cheap pre-check: bound the response before reading when the length is known.
|
|
228
|
+
const declaredLength = Number.parseInt(res.headers.get('Content-Length') ?? '', 10);
|
|
229
|
+
if (!Number.isNaN(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
|
|
230
|
+
return {
|
|
231
|
+
error: {
|
|
232
|
+
code: 'RESPONSE_TOO_LARGE',
|
|
233
|
+
message: `The response is ${declaredLength} bytes, over the ${MAX_RESPONSE_BYTES}-byte limit. Narrow the request with pagination or filters.`,
|
|
234
|
+
status: res.status,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const tooLarge = {
|
|
239
|
+
error: {
|
|
240
|
+
code: 'RESPONSE_TOO_LARGE',
|
|
241
|
+
message: `The response body exceeds the ${MAX_RESPONSE_BYTES}-byte limit. Narrow the request with pagination or filters.`,
|
|
242
|
+
status: res.status,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
// A chunked/streamed response carries no Content-Length, so bound it AS we
|
|
246
|
+
// read: accumulate chunks with a running byte counter and abort the moment
|
|
247
|
+
// the counter crosses the ceiling — never buffering the whole oversized body.
|
|
248
|
+
// The request timeout keeps running while the body streams, so a read can
|
|
249
|
+
// also abort here — map that to the same structured TIMEOUT.
|
|
250
|
+
let text;
|
|
251
|
+
try {
|
|
252
|
+
text = await this.readBody(res, tooLarge);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
if (err instanceof DOMException &&
|
|
256
|
+
(err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
257
|
+
return {
|
|
258
|
+
error: {
|
|
259
|
+
code: 'TIMEOUT',
|
|
260
|
+
message: `The request timed out after ${Math.round(this.timeoutMs / 1000)} seconds while reading the response. Try again, or narrow the request.`,
|
|
261
|
+
status: 0,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
error: {
|
|
267
|
+
code: 'NETWORK_ERROR',
|
|
268
|
+
message: err instanceof Error ? err.message : 'Reading the response failed.',
|
|
269
|
+
status: 0,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (typeof text !== 'string') {
|
|
274
|
+
return text; // the bounded reader returned the too-large error result
|
|
275
|
+
}
|
|
276
|
+
let body = null;
|
|
277
|
+
if (text.length > 0) {
|
|
278
|
+
try {
|
|
279
|
+
body = JSON.parse(text);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
body = text;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (res.ok) {
|
|
286
|
+
return { data: body };
|
|
287
|
+
}
|
|
288
|
+
return { error: normalizeError(res, body) };
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Reads a response body with the byte ceiling enforced mid-stream. Returns
|
|
292
|
+
* the decoded text, or the supplied too-large error result when the ceiling
|
|
293
|
+
* is crossed. Abort/timeout rejections propagate to the caller for mapping.
|
|
294
|
+
*/
|
|
295
|
+
async readBody(res, tooLarge) {
|
|
296
|
+
if (res.body) {
|
|
297
|
+
const reader = res.body.getReader();
|
|
298
|
+
const chunks = [];
|
|
299
|
+
let total = 0;
|
|
300
|
+
for (;;) {
|
|
301
|
+
const { done, value } = await reader.read();
|
|
302
|
+
if (done)
|
|
303
|
+
break;
|
|
304
|
+
if (value) {
|
|
305
|
+
total += value.byteLength;
|
|
306
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
307
|
+
// cancel() can reject (e.g. an already-errored stream); swallow it so
|
|
308
|
+
// an oversized response ALWAYS returns RESPONSE_TOO_LARGE.
|
|
309
|
+
try {
|
|
310
|
+
await reader.cancel();
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// best-effort cleanup — the size bound is what matters here.
|
|
314
|
+
}
|
|
315
|
+
return tooLarge;
|
|
316
|
+
}
|
|
317
|
+
chunks.push(value);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const merged = new Uint8Array(total);
|
|
321
|
+
let offset = 0;
|
|
322
|
+
for (const chunk of chunks) {
|
|
323
|
+
merged.set(chunk, offset);
|
|
324
|
+
offset += chunk.byteLength;
|
|
325
|
+
}
|
|
326
|
+
return new TextDecoder('utf-8').decode(merged);
|
|
327
|
+
}
|
|
328
|
+
// No readable stream (some test stubs) — fall back to text() and measure
|
|
329
|
+
// the true byte length as a backstop (multi-byte chars exceed char count).
|
|
330
|
+
const text = await res.text();
|
|
331
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
|
|
332
|
+
return tooLarge;
|
|
333
|
+
}
|
|
334
|
+
return text;
|
|
335
|
+
}
|
|
336
|
+
get(path, query) {
|
|
337
|
+
return this.send('GET', path, { query });
|
|
338
|
+
}
|
|
339
|
+
post(path, body, opts) {
|
|
340
|
+
return this.send('POST', path, {
|
|
341
|
+
body,
|
|
342
|
+
idempotency: opts?.idempotency,
|
|
343
|
+
idempotencyKey: opts?.idempotencyKey,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
patch(path, body) {
|
|
347
|
+
return this.send('PATCH', path, { body });
|
|
348
|
+
}
|
|
349
|
+
put(path, body, opts) {
|
|
350
|
+
return this.send('PUT', path, {
|
|
351
|
+
body,
|
|
352
|
+
idempotency: opts?.idempotency,
|
|
353
|
+
idempotencyKey: opts?.idempotencyKey,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
delete(path) {
|
|
357
|
+
return this.send('DELETE', path);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Sends a multipart/form-data POST with a single file field plus optional
|
|
361
|
+
* extra string fields. A missing/unreadable file yields a FILE_NOT_FOUND
|
|
362
|
+
* error result rather than throwing.
|
|
363
|
+
*/
|
|
364
|
+
async postMultipart(path, filePath, fieldName, extra) {
|
|
365
|
+
let bytes;
|
|
366
|
+
try {
|
|
367
|
+
bytes = await readFile(filePath);
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
return {
|
|
371
|
+
error: {
|
|
372
|
+
code: 'FILE_NOT_FOUND',
|
|
373
|
+
message: `Could not read file at ${filePath}: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
374
|
+
status: 0,
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
const form = new FormData();
|
|
379
|
+
form.append(fieldName, new Blob([new Uint8Array(bytes)], { type: contentType(filePath) }), basename(filePath));
|
|
380
|
+
for (const [key, value] of Object.entries(extra ?? {})) {
|
|
381
|
+
form.append(key, value);
|
|
382
|
+
}
|
|
383
|
+
// Let fetch set the multipart Content-Type boundary; do not override it.
|
|
384
|
+
return this.send('POST', path, { rawBody: form });
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Performs a raw request with NO Authorization header — used for presigned
|
|
388
|
+
* upload PUTs, where the signed URL is already the credential and an extra
|
|
389
|
+
* Bearer token would break the signature.
|
|
390
|
+
*/
|
|
391
|
+
raw(url, init) {
|
|
392
|
+
return this.fetchFn(url, { signal: AbortSignal.timeout(this.rawTimeoutMs), ...init });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presigned-URL upload helper.
|
|
3
|
+
*
|
|
4
|
+
* A large binary asset is never streamed through the LabelGrid API. Instead the
|
|
5
|
+
* flow is three steps:
|
|
6
|
+
* 1. POST the upload-url endpoint (with the filename) to mint a short-lived
|
|
7
|
+
* presigned storage URL and its object key.
|
|
8
|
+
* 2. PUT the file bytes straight to that presigned URL. This request carries
|
|
9
|
+
* NO Authorization header — the signature in the URL is the credential, and
|
|
10
|
+
* an extra Bearer token would break it.
|
|
11
|
+
* 3. PUT the commit endpoint with the returned object key (with an idempotency
|
|
12
|
+
* key) so the API records the finalized file.
|
|
13
|
+
*
|
|
14
|
+
* A failure at step 2 aborts before the commit, so a half-uploaded object is
|
|
15
|
+
* never finalized. Business rules (format checks, transcoding) stay server-side.
|
|
16
|
+
*/
|
|
17
|
+
import type { ApiResult, LabelGridClient } from './http.js';
|
|
18
|
+
/**
|
|
19
|
+
* The structural subset of {@link LabelGridClient} the presigned-upload flow
|
|
20
|
+
* needs. Declared as a Pick so any object with these methods (including a test
|
|
21
|
+
* stub) can drive the flow — a class type would demand the private fields too.
|
|
22
|
+
*/
|
|
23
|
+
export type UploadHttp = Pick<LabelGridClient, 'post' | 'put' | 'raw'>;
|
|
24
|
+
export type UploadOptions = {
|
|
25
|
+
/** The endpoint that mints the presigned URL, e.g. /tracks/42/files/stereo/upload-url. */
|
|
26
|
+
uploadUrlPath: string;
|
|
27
|
+
/** The endpoint that records the finalized file, e.g. /tracks/42/files/stereo. */
|
|
28
|
+
commitPath: string;
|
|
29
|
+
/** Absolute or relative local path to the file to upload. */
|
|
30
|
+
filePath: string;
|
|
31
|
+
};
|
|
32
|
+
export declare function uploadViaPresignedUrl(client: UploadHttp, opts: UploadOptions): Promise<ApiResult<unknown>>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presigned-URL upload helper.
|
|
3
|
+
*
|
|
4
|
+
* A large binary asset is never streamed through the LabelGrid API. Instead the
|
|
5
|
+
* flow is three steps:
|
|
6
|
+
* 1. POST the upload-url endpoint (with the filename) to mint a short-lived
|
|
7
|
+
* presigned storage URL and its object key.
|
|
8
|
+
* 2. PUT the file bytes straight to that presigned URL. This request carries
|
|
9
|
+
* NO Authorization header — the signature in the URL is the credential, and
|
|
10
|
+
* an extra Bearer token would break it.
|
|
11
|
+
* 3. PUT the commit endpoint with the returned object key (with an idempotency
|
|
12
|
+
* key) so the API records the finalized file.
|
|
13
|
+
*
|
|
14
|
+
* A failure at step 2 aborts before the commit, so a half-uploaded object is
|
|
15
|
+
* never finalized. Business rules (format checks, transcoding) stay server-side.
|
|
16
|
+
*/
|
|
17
|
+
import { statSync } from 'node:fs';
|
|
18
|
+
import { readFile } from 'node:fs/promises';
|
|
19
|
+
import { basename } from 'node:path';
|
|
20
|
+
import { log } from '../log.js';
|
|
21
|
+
import { contentType } from './content-types.js';
|
|
22
|
+
/** True only for an existing regular file. */
|
|
23
|
+
function isReadableFile(p) {
|
|
24
|
+
try {
|
|
25
|
+
return statSync(p).isFile();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function uploadViaPresignedUrl(client, opts) {
|
|
32
|
+
// Fail fast and locally: never touch the network for a file we cannot read.
|
|
33
|
+
if (!isReadableFile(opts.filePath)) {
|
|
34
|
+
const error = {
|
|
35
|
+
code: 'FILE_NOT_FOUND',
|
|
36
|
+
message: `No readable file at ${opts.filePath}.`,
|
|
37
|
+
status: 0,
|
|
38
|
+
};
|
|
39
|
+
return { error };
|
|
40
|
+
}
|
|
41
|
+
// Step 1: mint the presigned URL.
|
|
42
|
+
const minted = await client.post(opts.uploadUrlPath, {
|
|
43
|
+
filename: basename(opts.filePath),
|
|
44
|
+
});
|
|
45
|
+
if ('error' in minted)
|
|
46
|
+
return minted;
|
|
47
|
+
const uploadUrl = minted.data?.upload_url;
|
|
48
|
+
const key = minted.data?.key;
|
|
49
|
+
if (typeof uploadUrl !== 'string' || typeof key !== 'string') {
|
|
50
|
+
const error = {
|
|
51
|
+
code: 'UPLOAD_URL_INVALID',
|
|
52
|
+
message: 'The upload-url response did not contain a usable upload_url and key.',
|
|
53
|
+
status: 0,
|
|
54
|
+
};
|
|
55
|
+
return { error };
|
|
56
|
+
}
|
|
57
|
+
// Step 2: PUT the bytes directly to storage — NO auth header (the URL is signed).
|
|
58
|
+
// The file passed isReadableFile above, but it can vanish before this read
|
|
59
|
+
// (a TOCTOU race); a structured FILE_NOT_FOUND is the contract, not a throw.
|
|
60
|
+
let bytes;
|
|
61
|
+
try {
|
|
62
|
+
bytes = await readFile(opts.filePath);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
const error = {
|
|
66
|
+
code: 'FILE_NOT_FOUND',
|
|
67
|
+
message: `The file at ${opts.filePath} could not be read.`,
|
|
68
|
+
status: 0,
|
|
69
|
+
};
|
|
70
|
+
return { error };
|
|
71
|
+
}
|
|
72
|
+
let putRes;
|
|
73
|
+
try {
|
|
74
|
+
putRes = await client.raw(uploadUrl, {
|
|
75
|
+
method: 'PUT',
|
|
76
|
+
headers: { 'Content-Type': contentType(opts.filePath) },
|
|
77
|
+
body: new Uint8Array(bytes),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
// Never surface err.message raw to the log — it can embed the signed URL,
|
|
82
|
+
// and `reason` is not a redacted key. Strip any URL before logging.
|
|
83
|
+
log('error', 'presigned upload PUT failed', {
|
|
84
|
+
reason: err instanceof Error ? err.message.replace(/https?:\/\/\S+/gi, '[url]') : 'network error',
|
|
85
|
+
});
|
|
86
|
+
const error = {
|
|
87
|
+
code: 'UPLOAD_FAILED',
|
|
88
|
+
message: 'Uploading the file to storage failed.',
|
|
89
|
+
status: 0,
|
|
90
|
+
};
|
|
91
|
+
return { error };
|
|
92
|
+
}
|
|
93
|
+
if (!putRes.ok) {
|
|
94
|
+
// Abort BEFORE the commit — a half-uploaded object is never finalized.
|
|
95
|
+
const error = {
|
|
96
|
+
code: 'UPLOAD_FAILED',
|
|
97
|
+
message: `Uploading the file to storage failed with status ${putRes.status}.`,
|
|
98
|
+
status: putRes.status,
|
|
99
|
+
};
|
|
100
|
+
return { error };
|
|
101
|
+
}
|
|
102
|
+
// Step 3: commit the object key (idempotent — a retried commit will not duplicate).
|
|
103
|
+
return client.put(opts.commitPath, { s3_key: key }, { idempotency: true });
|
|
104
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalog-entity registry: the six entity kinds the consolidated catalog
|
|
3
|
+
* tools operate on, each with its endpoint path and the reviewed documentation
|
|
4
|
+
* fragments (list filters, create/update fields, delete refusals) the tool
|
|
5
|
+
* descriptions are assembled from.
|
|
6
|
+
*
|
|
7
|
+
* This is data, not behavior — the catalog tools stay thin wrappers and the
|
|
8
|
+
* API owns all validation. The wording here carries the caveats from the
|
|
9
|
+
* per-entity tool descriptions it replaces (recording_country on track create,
|
|
10
|
+
* RELEASE_LOCKED_FIELDS on release update, the delete refusals).
|
|
11
|
+
*/
|
|
12
|
+
export type EntityName = 'label' | 'artist' | 'writer' | 'publisher' | 'release' | 'track';
|
|
13
|
+
/** The entity names as a tuple, for zod enum inputs. */
|
|
14
|
+
export declare const ENTITY_NAMES: readonly ["label", "artist", "writer", "publisher", "release", "track"];
|
|
15
|
+
export type EntitySpec = {
|
|
16
|
+
/** The collection endpoint path, e.g. '/labels'. */
|
|
17
|
+
path: string;
|
|
18
|
+
/** One-line doc of the useful list filters for search_catalog. */
|
|
19
|
+
filtersDoc: string;
|
|
20
|
+
/** One-line doc of required + common create/update fields. */
|
|
21
|
+
fieldsDoc: string;
|
|
22
|
+
/** One-line doc of the server-side delete refusals. */
|
|
23
|
+
deleteNote: string;
|
|
24
|
+
};
|
|
25
|
+
export declare const ENTITIES: Record<EntityName, EntitySpec>;
|
package/dist/entities.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalog-entity registry: the six entity kinds the consolidated catalog
|
|
3
|
+
* tools operate on, each with its endpoint path and the reviewed documentation
|
|
4
|
+
* fragments (list filters, create/update fields, delete refusals) the tool
|
|
5
|
+
* descriptions are assembled from.
|
|
6
|
+
*
|
|
7
|
+
* This is data, not behavior — the catalog tools stay thin wrappers and the
|
|
8
|
+
* API owns all validation. The wording here carries the caveats from the
|
|
9
|
+
* per-entity tool descriptions it replaces (recording_country on track create,
|
|
10
|
+
* RELEASE_LOCKED_FIELDS on release update, the delete refusals).
|
|
11
|
+
*/
|
|
12
|
+
/** The entity names as a tuple, for zod enum inputs. */
|
|
13
|
+
export const ENTITY_NAMES = ['label', 'artist', 'writer', 'publisher', 'release', 'track'];
|
|
14
|
+
export const ENTITIES = {
|
|
15
|
+
label: {
|
|
16
|
+
path: '/labels',
|
|
17
|
+
filtersDoc: 'label: no documented filters — paginate with page/per_page.',
|
|
18
|
+
fieldsDoc: 'label — required: name, default_email; optional: support email, website/platform URLs, default copyright lines, isrc_base.',
|
|
19
|
+
deleteNote: 'label: refused while the label still has releases — remove or reassign its releases first.',
|
|
20
|
+
},
|
|
21
|
+
artist: {
|
|
22
|
+
path: '/artists',
|
|
23
|
+
filtersDoc: 'artist: artist_name (filter by artist name).',
|
|
24
|
+
fieldsDoc: 'artist — required: artist_name; optional: full_name, email, location, bios, isni, default_language, platform profile URLs.',
|
|
25
|
+
deleteNote: 'artist: refused while still referenced by releases or tracks.',
|
|
26
|
+
},
|
|
27
|
+
writer: {
|
|
28
|
+
path: '/writers',
|
|
29
|
+
filtersDoc: 'writer: name (writer name), ipi (IPI number).',
|
|
30
|
+
fieldsDoc: 'writer — required: first_name, last_name; optional: middle_name, display_credits, email, country, pro, ipi, isni, publisher_id (or publisher_name/publisher_pro/publisher_ipi).',
|
|
31
|
+
deleteNote: 'writer: refused while still referenced by tracks.',
|
|
32
|
+
},
|
|
33
|
+
publisher: {
|
|
34
|
+
path: '/publishers',
|
|
35
|
+
filtersDoc: 'publisher: name (publisher name), ipi (IPI number).',
|
|
36
|
+
fieldsDoc: 'publisher — required: name; optional: ipi, pro, isni, controlled_publisher.',
|
|
37
|
+
deleteNote: 'publisher: refused while still referenced by writers.',
|
|
38
|
+
},
|
|
39
|
+
release: {
|
|
40
|
+
path: '/releases',
|
|
41
|
+
filtersDoc: 'release: label_id (owning label id), is_live (1 = live/distributed only), barcode_number (UPC/EAN), cat (catalog number).',
|
|
42
|
+
fieldsDoc: 'release — required on create: content_type, label_id, artists, titles, cat (catalog number), artwork_ai_usage, primary_genre_id; many optional fields (dates, copyright lines, genres, per-outlet URLs). Once submitted or distributed some fields are locked — changing one returns a 403 with code RELEASE_LOCKED_FIELDS naming exactly which fields cannot change.',
|
|
43
|
+
deleteNote: 'release: only a never-submitted draft can be deleted.',
|
|
44
|
+
},
|
|
45
|
+
track: {
|
|
46
|
+
path: '/tracks',
|
|
47
|
+
filtersDoc: 'track: release_id (one release’s tracks), isrc (filter by ISRC).',
|
|
48
|
+
fieldsDoc: 'track — required on create: release_id, disc, track_num, composition_type, artists, audio_ai_usage, composition_ai_usage, commercial_samples, audio_language, contributors, and recording_country (ISO 3166-1 alpha-2, e.g. "US"); optional: titles, isrc, iswc, writers, publishers, splits, and more.',
|
|
49
|
+
deleteNote: 'track: allowed while the parent release is an editable draft; refused once submitted or distributed.',
|
|
50
|
+
},
|
|
51
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @labelgrid/core — the shared LabelGrid public-API client.
|
|
3
|
+
*
|
|
4
|
+
* One surface for every LabelGrid tool built on the public API: the HTTP
|
|
5
|
+
* transport with structured errors, presigned-URL uploads with extension
|
|
6
|
+
* allowlists, upload content-type resolution, the catalog-entity registry,
|
|
7
|
+
* and stderr-only logging with secret redaction.
|
|
8
|
+
*/
|
|
9
|
+
export * from './api/content-types.js';
|
|
10
|
+
export * from './api/http.js';
|
|
11
|
+
export * from './api/upload.js';
|
|
12
|
+
export * from './entities.js';
|
|
13
|
+
export * from './log.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @labelgrid/core — the shared LabelGrid public-API client.
|
|
3
|
+
*
|
|
4
|
+
* One surface for every LabelGrid tool built on the public API: the HTTP
|
|
5
|
+
* transport with structured errors, presigned-URL uploads with extension
|
|
6
|
+
* allowlists, upload content-type resolution, the catalog-entity registry,
|
|
7
|
+
* and stderr-only logging with secret redaction.
|
|
8
|
+
*/
|
|
9
|
+
export * from './api/content-types.js';
|
|
10
|
+
export * from './api/http.js';
|
|
11
|
+
export * from './api/upload.js';
|
|
12
|
+
export * from './entities.js';
|
|
13
|
+
export * from './log.js';
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stderr-only structured logging with secret redaction.
|
|
3
|
+
*
|
|
4
|
+
* stdout is reserved for the MCP protocol stream, so every log line goes to
|
|
5
|
+
* stderr. Any structured metadata is passed through {@link redactSecrets}
|
|
6
|
+
* first so tokens, passwords and signed URLs never reach the log.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Deep-clones a value, replacing the value of any object key whose name looks
|
|
10
|
+
* like a secret with a fixed mask. Non-secret values, arrays and primitives are
|
|
11
|
+
* preserved (arrays and nested objects are walked recursively).
|
|
12
|
+
*/
|
|
13
|
+
export declare function redactSecrets(v: unknown): unknown;
|
|
14
|
+
export type LogLevel = 'info' | 'warn' | 'error';
|
|
15
|
+
/** Writes a single redacted log line to stderr (never stdout). */
|
|
16
|
+
export declare function log(level: LogLevel, msg: string, meta?: unknown): void;
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stderr-only structured logging with secret redaction.
|
|
3
|
+
*
|
|
4
|
+
* stdout is reserved for the MCP protocol stream, so every log line goes to
|
|
5
|
+
* stderr. Any structured metadata is passed through {@link redactSecrets}
|
|
6
|
+
* first so tokens, passwords and signed URLs never reach the log.
|
|
7
|
+
*/
|
|
8
|
+
const SECRET_KEY = /token|password|secret|nonce|authorization|key/i;
|
|
9
|
+
const MASK = '***REDACTED***';
|
|
10
|
+
/**
|
|
11
|
+
* Deep-clones a value, replacing the value of any object key whose name looks
|
|
12
|
+
* like a secret with a fixed mask. Non-secret values, arrays and primitives are
|
|
13
|
+
* preserved (arrays and nested objects are walked recursively).
|
|
14
|
+
*/
|
|
15
|
+
export function redactSecrets(v) {
|
|
16
|
+
if (Array.isArray(v)) {
|
|
17
|
+
return v.map((item) => redactSecrets(item));
|
|
18
|
+
}
|
|
19
|
+
if (v !== null && typeof v === 'object') {
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const [key, value] of Object.entries(v)) {
|
|
22
|
+
out[key] = SECRET_KEY.test(key) ? MASK : redactSecrets(value);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
return v;
|
|
27
|
+
}
|
|
28
|
+
/** Writes a single redacted log line to stderr (never stdout). */
|
|
29
|
+
export function log(level, msg, meta) {
|
|
30
|
+
let line = `[${level}] ${msg}`;
|
|
31
|
+
if (meta !== undefined) {
|
|
32
|
+
line += ` ${JSON.stringify(redactSecrets(meta))}`;
|
|
33
|
+
}
|
|
34
|
+
process.stderr.write(`${line}\n`);
|
|
35
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@labelgrid/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared LabelGrid public-API client: HTTP transport, uploads, content types, the catalog-entity registry, and redacting logging",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": ["labelgrid", "music-distribution", "api-client"],
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist", "README.md", "CHANGELOG.md", "LICENSE"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"dev": "tsc --watch",
|
|
19
|
+
"test": "vitest run"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/labelgrid/labelgrid-mcp.git",
|
|
24
|
+
"directory": "packages/core"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
}
|
|
30
|
+
}
|