diff --git a/src/endpoints/sdk/apps.tools.ts b/src/endpoints/sdk/apps.tools.ts index 26597ad..f8d0932 100644 --- a/src/endpoints/sdk/apps.tools.ts +++ b/src/endpoints/sdk/apps.tools.ts @@ -26,7 +26,7 @@ export const tools: MakeTool[] = [ { name: 'sdk-apps_get', title: 'Get SDK app', - description: 'Get a SDK app by name and version.', + description: 'Get an SDK app by name and version.', category: 'sdk-apps', scope: 'sdk-apps:read', scopeId: undefined, @@ -154,7 +154,7 @@ export const tools: MakeTool[] = [ { name: 'sdk-apps_delete', title: 'Delete SDK app', - description: 'Delete a SDK app by name and version.', + description: 'Delete an SDK app by name and version.', category: 'sdk-apps', scope: 'sdk-apps:write', scopeId: undefined, @@ -179,7 +179,7 @@ export const tools: MakeTool[] = [ { name: 'sdk-apps_get-section', title: 'Get SDK app section', - description: 'Get a specific section of a SDK app.', + description: 'Get a specific section of an SDK app.', category: 'sdk-apps', scope: 'sdk-apps:read', scopeId: undefined, @@ -211,7 +211,7 @@ export const tools: MakeTool[] = [ { name: 'sdk-apps_set-section', title: 'Set SDK app section', - description: 'Set/update a specific section of a SDK app.', + description: 'Set/update a specific section of an SDK app.', category: 'sdk-apps', scope: 'sdk-apps:write', scopeId: undefined, @@ -323,6 +323,105 @@ export const tools: MakeTool[] = [ return await make.sdk.apps.getCommon(args.name, args.version); }, }, + { + name: 'sdk-apps_set-icon', + title: 'Set SDK app icon', + description: 'Upload an icon for an SDK app.', + category: 'sdk-apps', + scope: 'sdk-apps:write', + scopeId: undefined, + identifier: undefined, + annotations: { + idempotentHint: true, + destructiveHint: false, + }, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name of the app' }, + version: { type: 'number', description: 'The version of the app' }, + dataBase64: { type: 'string', description: 'Base64-encoded 512x512 PNG icon data to upload' }, + }, + required: ['name', 'version', 'dataBase64'], + }, + examples: [{ name: 'my-app', version: 1, dataBase64: 'iVBORw0KGgo...' }], + execute: async (make: Make, args: { name: string; version: number; dataBase64: string }) => { + await make.sdk.apps.setIcon(args.name, args.version, Buffer.from(args.dataBase64, 'base64')); + return `Icon has been set.`; + }, + }, + { + name: 'sdk-apps_get-icon', + title: 'Get SDK app icon', + description: 'Download an SDK app icon and return it as base64-encoded PNG data.', + category: 'sdk-apps', + scope: 'sdk-apps:read', + scopeId: undefined, + identifier: undefined, + annotations: { + readOnlyHint: true, + }, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name of the app' }, + version: { type: 'number', description: 'The version of the app' }, + size: { type: 'number', description: 'Icon size to download', default: 512 }, + }, + required: ['name', 'version'], + }, + examples: [{ name: 'my-app', version: 1, size: 512 }], + execute: async (make: Make, args: { name: string; version: number; size?: number }) => { + const icon = Buffer.from(await make.sdk.apps.getIcon(args.name, args.version, args.size ?? 512)); + return icon.toString('base64'); + }, + }, + { + name: 'sdk-apps_set-public', + title: 'Set SDK app public', + description: 'Mark an SDK app version as public.', + category: 'sdk-apps', + scope: 'sdk-apps:write', + scopeId: undefined, + identifier: undefined, + annotations: { idempotentHint: true, destructiveHint: false }, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name of the app' }, + version: { type: 'number', description: 'The version of the app' }, + }, + required: ['name', 'version'], + }, + examples: [{ name: 'my-app', version: 1 }], + execute: async (make: Make, args: { name: string; version: number }) => { + await make.sdk.apps.makePublic(args.name, args.version); + return `App has been made public.`; + }, + }, + { + name: 'sdk-apps_set-private', + title: 'Set SDK app private', + description: 'Mark an SDK app version as private.', + category: 'sdk-apps', + scope: 'sdk-apps:write', + scopeId: undefined, + identifier: undefined, + annotations: { idempotentHint: true, destructiveHint: false }, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name of the app' }, + version: { type: 'number', description: 'The version of the app' }, + }, + required: ['name', 'version'], + }, + examples: [{ name: 'my-app', version: 1 }], + execute: async (make: Make, args: { name: string; version: number }) => { + await make.sdk.apps.makePrivate(args.name, args.version); + return `App has been made private.`; + }, + }, { name: 'sdk-apps_set-common', title: 'Set SDK app common data', diff --git a/src/endpoints/sdk/apps.ts b/src/endpoints/sdk/apps.ts index a115416..722ddc0 100644 --- a/src/endpoints/sdk/apps.ts +++ b/src/endpoints/sdk/apps.ts @@ -137,6 +137,44 @@ type UpdateSDKAppResponse = { app: Pick; }; +type IconUploadResponse = { + changed: boolean; +}; + +/** PNG file signature (first 8 bytes). */ +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +/** IHDR chunk type marker ("IHDR"), located at bytes 12-15 of a PNG. */ +const PNG_IHDR = [0x49, 0x48, 0x44, 0x52]; + +/** Required app icon dimensions (square, in pixels). */ +const APP_ICON_SIZE = 512; + +/** + * Validate that an icon payload is a 512x512 PNG before upload, mirroring the + * server constraint so callers fail fast with a clear error. Platform-neutral + * (works with both `Uint8Array` and `ArrayBuffer`, no Node `Buffer` required). + */ +function assertAppIcon(iconData: Uint8Array | ArrayBuffer): void { + const bytes = iconData instanceof Uint8Array ? iconData : new Uint8Array(iconData); + if (bytes.length < 24 || PNG_SIGNATURE.some((byte, index) => bytes[index] !== byte)) { + throw new Error('App icon must be a PNG image.'); + } + + if (PNG_IHDR.some((byte, index) => bytes[12 + index] !== byte)) { + throw new Error('App icon is not a valid PNG image: missing IHDR chunk.'); + } + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = view.getUint32(16); + const height = view.getUint32(20); + if (width !== APP_ICON_SIZE || height !== APP_ICON_SIZE) { + throw new Error( + `App icon must be ${APP_ICON_SIZE}x${APP_ICON_SIZE} PNG. Got ${width}x${height}. Resize it first.`, + ); + } +} + /** * Class providing methods for working with Apps */ @@ -265,4 +303,44 @@ export class SDKApps { }); return response.changed; } + + /** + * Upload an app icon. The icon must be a 512x512 PNG; otherwise an error is thrown before upload. + */ + async setIcon(name: string, version: number, iconData: Uint8Array | ArrayBuffer): Promise { + assertAppIcon(iconData); + const response = await this.#fetch(`/sdk/apps/${name}/${version}/icon`, { + method: 'PUT', + headers: { + 'Content-Type': 'image/png', + }, + body: iconData, + }); + return response.changed; + } + + /** + * Download an app icon at a given rendered size. + */ + async getIcon(name: string, version: number, size = 512): Promise { + return await this.#fetch(`/sdk/apps/${name}/${version}/icon/${size}`); + } + + /** + * Make app private. + */ + async makePrivate(name: string, version: number): Promise { + await this.#fetch(`/sdk/apps/${name}/${version}/private`, { + method: 'POST', + }); + } + + /** + * Make app public. + */ + async makePublic(name: string, version: number): Promise { + await this.#fetch(`/sdk/apps/${name}/${version}/public`, { + method: 'POST', + }); + } } diff --git a/src/endpoints/sdk/modules.tools.ts b/src/endpoints/sdk/modules.tools.ts index ce14191..983cd56 100644 --- a/src/endpoints/sdk/modules.tools.ts +++ b/src/endpoints/sdk/modules.tools.ts @@ -210,6 +210,54 @@ export const tools: MakeTool[] = [ return await make.sdk.modules.getSection(args.appName, args.appVersion, args.moduleName, args.section); }, }, + { + name: 'sdk-modules_set-public', + title: 'Set SDK module public', + description: 'Mark an SDK app module as public.', + category: 'sdk-modules', + scope: 'sdk-apps:write', + scopeId: undefined, + identifier: undefined, + annotations: { idempotentHint: true, destructiveHint: false }, + inputSchema: { + type: 'object', + properties: { + appName: { type: 'string', description: 'The name of the app' }, + appVersion: { type: 'number', description: 'The version of the app' }, + moduleName: { type: 'string', description: 'The name of the module' }, + }, + required: ['appName', 'appVersion', 'moduleName'], + }, + examples: [{ appName: 'my-app', appVersion: 1, moduleName: 'listItems' }], + execute: async (make: Make, args: { appName: string; appVersion: number; moduleName: string }) => { + await make.sdk.modules.makePublic(args.appName, args.appVersion, args.moduleName); + return `Module has been made public.`; + }, + }, + { + name: 'sdk-modules_set-private', + title: 'Set SDK module private', + description: 'Mark an SDK app module as private.', + category: 'sdk-modules', + scope: 'sdk-apps:write', + scopeId: undefined, + identifier: undefined, + annotations: { idempotentHint: true, destructiveHint: false }, + inputSchema: { + type: 'object', + properties: { + appName: { type: 'string', description: 'The name of the app' }, + appVersion: { type: 'number', description: 'The version of the app' }, + moduleName: { type: 'string', description: 'The name of the module' }, + }, + required: ['appName', 'appVersion', 'moduleName'], + }, + examples: [{ appName: 'my-app', appVersion: 1, moduleName: 'listItems' }], + execute: async (make: Make, args: { appName: string; appVersion: number; moduleName: string }) => { + await make.sdk.modules.makePrivate(args.appName, args.appVersion, args.moduleName); + return `Module has been made private.`; + }, + }, { name: 'sdk-modules_set-section', title: 'Set SDK module section', diff --git a/src/endpoints/sdk/modules.ts b/src/endpoints/sdk/modules.ts index c22f04b..6f95df6 100644 --- a/src/endpoints/sdk/modules.ts +++ b/src/endpoints/sdk/modules.ts @@ -187,4 +187,18 @@ export class SDKModules { body: JSONStringifyIfNotString(body), }); } + + /** + * Make a module private. + */ + async makePrivate(appName: string, appVersion: number, moduleName: string): Promise { + await this.#fetch(`/sdk/apps/${appName}/${appVersion}/modules/${moduleName}/private`, { method: 'POST' }); + } + + /** + * Make a module public. + */ + async makePublic(appName: string, appVersion: number, moduleName: string): Promise { + await this.#fetch(`/sdk/apps/${appName}/${appVersion}/modules/${moduleName}/public`, { method: 'POST' }); + } } diff --git a/src/make.ts b/src/make.ts index 8e8a903..e1209a7 100644 --- a/src/make.ts +++ b/src/make.ts @@ -389,20 +389,28 @@ export class Make { /** * Prepare the request body for API calls * - * @param body The request body - can be an object, string, or undefined + * Objects and arrays are JSON-serialized (setting the JSON content-type), + * strings are passed through unchanged, and raw binary payloads + * (`Uint8Array`/`ArrayBuffer`) are returned as-is so the caller controls the + * content-type. + * + * @param body The request body - an object/array, string, raw binary payload, or undefined * @param headers The headers object to potentially modify the content-type - * @returns The body serialized as a string + * @returns The JSON string for objects/arrays, or the string/binary/undefined body unchanged * @protected */ protected prepareBody( - body: Record | Array | string | undefined, + body: Record | Array | string | Uint8Array | ArrayBuffer | undefined, headers: Record, - ): string { + ): string | Uint8Array | ArrayBuffer | undefined { + if (body instanceof Uint8Array || body instanceof ArrayBuffer) { + return body; + } if (body && typeof body !== 'string') { headers['content-type'] = 'application/json'; return JSON.stringify(body); } - return body as string; + return body as string | undefined; } /** @@ -485,8 +493,9 @@ export class Make { /** * Handle successful API responses * - * Parses the response based on content-type header. - * JSON responses are parsed as objects, other responses as text. + * Parses the response based on the content-type header: JSON responses are + * parsed as objects, binary responses (e.g. `image/*` or + * `application/octet-stream`) as an ArrayBuffer, and everything else as text. * * @template T The expected response type * @param response The successful response from the API @@ -498,8 +507,16 @@ export class Make { const isJsonType: boolean = Boolean( contentType === 'application/json' || contentType?.startsWith('application/json;'), ); //prevent application/jsonc to be parsed as json + const isBinaryType: boolean = Boolean( + contentType?.startsWith('image/') || contentType?.startsWith('application/octet-stream'), + ); - const result = isJsonType ? await response.json() : await response.text(); - return result as T; + if (isJsonType) { + return (await response.json()) as T; + } + if (isBinaryType) { + return (await response.arrayBuffer()) as T; + } + return (await response.text()) as T; } } diff --git a/src/types.ts b/src/types.ts index 5abeb2d..fa8ea51 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,8 +31,8 @@ export type FetchOptions = { headers?: Record; /** Query parameters to append to the URL */ query?: Record; - /** Request body as an object or string */ - body?: Record | Array | string; + /** Request body as an object, string, or raw binary payload */ + body?: Record | Array | string | Uint8Array | ArrayBuffer; /** HTTP method (GET, POST, PATCH, etc.) */ method?: string; }; diff --git a/test/sdk/apps.spec.ts b/test/sdk/apps.spec.ts index b0ddaa1..d80c5a7 100644 --- a/test/sdk/apps.spec.ts +++ b/test/sdk/apps.spec.ts @@ -16,6 +16,16 @@ import { join } from 'path'; const MAKE_API_KEY = 'api-key'; const MAKE_ZONE = 'make.local'; +/** Build a minimal valid PNG header (signature + IHDR) with the given dimensions. */ +function pngIcon(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(24); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG signature + bytes.set([0x49, 0x48, 0x44, 0x52], 12); // "IHDR" + new DataView(bytes.buffer).setUint32(16, width); + new DataView(bytes.buffer).setUint32(20, height); + return bytes; +} + describe('Endpoints: SDK > Apps', () => { const make = new Make(MAKE_API_KEY, MAKE_ZONE); @@ -144,4 +154,47 @@ describe('Endpoints: SDK > Apps', () => { const result = await make.sdk.apps.setCommon('test-app', 1, common); expect(result).toBe(true); }); + + it('Should upload app icon as raw PNG data', async () => { + const iconData = pngIcon(512, 512); + mockFetch('PUT https://make.local/api/v2/sdk/apps/test-app/1/icon', { changed: true }, req => { + expect(req.body).toBeInstanceOf(ArrayBuffer); + expect([...new Uint8Array(req.body as ArrayBuffer)]).toStrictEqual([...iconData]); + expect(req.headers.get('content-type')).toBe('image/png'); + }); + + const result = await make.sdk.apps.setIcon('test-app', 1, iconData); + expect(result).toBe(true); + }); + + it('Should reject a non-PNG app icon before upload', async () => { + await expect(make.sdk.apps.setIcon('test-app', 1, new Uint8Array([1, 2, 3, 4]))).rejects.toThrow( + 'App icon must be a PNG image.', + ); + }); + + it('Should reject an app icon that is not 512x512', async () => { + await expect(make.sdk.apps.setIcon('test-app', 1, pngIcon(256, 256))).rejects.toThrow( + 'App icon must be 512x512 PNG. Got 256x256.', + ); + }); + + it('Should download app icon as an ArrayBuffer', async () => { + const iconData = pngIcon(512, 512); + mockFetch('GET https://make.local/api/v2/sdk/apps/test-app/1/icon/512', iconData); + + const result = await make.sdk.apps.getIcon('test-app', 1); + expect(result).toBeInstanceOf(ArrayBuffer); + expect([...new Uint8Array(result)]).toStrictEqual([...iconData]); + }); + + it('Should make app public and private', async () => { + mockFetch( + ['POST https://make.local/api/v2/sdk/apps/test-app/1/public', { changed: true }, undefined], + ['POST https://make.local/api/v2/sdk/apps/test-app/1/private', { changed: true }, undefined], + ); + + await expect(make.sdk.apps.makePublic('test-app', 1)).resolves.toBeUndefined(); + await expect(make.sdk.apps.makePrivate('test-app', 1)).resolves.toBeUndefined(); + }); }); diff --git a/test/sdk/modules.spec.ts b/test/sdk/modules.spec.ts index d771bfb..19e71ab 100644 --- a/test/sdk/modules.spec.ts +++ b/test/sdk/modules.spec.ts @@ -115,4 +115,22 @@ describe('Endpoints: SDK > Modules', () => { await make.sdk.modules.setSection(appName, appVersion, moduleName, section, body); }); + + it('Should make module public and private', async () => { + mockFetch( + [ + `POST https://make.local/api/v2/sdk/apps/${appName}/${appVersion}/modules/${moduleName}/public`, + { changed: true }, + undefined, + ], + [ + `POST https://make.local/api/v2/sdk/apps/${appName}/${appVersion}/modules/${moduleName}/private`, + { changed: true }, + undefined, + ], + ); + + await expect(make.sdk.modules.makePublic(appName, appVersion, moduleName)).resolves.toBeUndefined(); + await expect(make.sdk.modules.makePrivate(appName, appVersion, moduleName)).resolves.toBeUndefined(); + }); }); diff --git a/test/test.utils.ts b/test/test.utils.ts index d2842f2..37385e6 100644 --- a/test/test.utils.ts +++ b/test/test.utils.ts @@ -6,7 +6,7 @@ enableFetchMocks(); type Mock = [string, unknown, number | Asserts | undefined]; type Asserts = (req: { - body: Record | Array | string; + body: Record | Array | string | ArrayBuffer; headers: Headers; method: string; url: string; @@ -55,20 +55,44 @@ export function mockFetch(...args: unknown[]): void { const isJsonType: boolean = Boolean( contentType === 'application/json' || contentType?.startsWith('application/json;'), ); //prevent application/jsonc to be parsed as json - const body = isJsonType ? await req.json() : await req.text(); + const isBinaryType = Boolean( + contentType?.startsWith('image/') || contentType?.startsWith('application/octet-stream'), + ); + const body = isJsonType ? await req.json() : isBinaryType ? await req.arrayBuffer() : await req.text(); mock.asserts({ - body: isObject(body) ? (body as Record) : Array.isArray(body) ? body : String(body), + body: + body instanceof ArrayBuffer + ? body + : isObject(body) + ? (body as Record) + : Array.isArray(body) + ? body + : String(body), headers: req.headers, method: req.method, url: req.url, }); } + const binaryBody = + mock.body instanceof Uint8Array + ? mock.body + : mock.body instanceof ArrayBuffer + ? new Uint8Array(mock.body) + : null; return Promise.resolve({ - body: typeof mock.body === 'string' ? mock.body : JSON.stringify(mock.body), + // Hand the raw bytes to Response so they survive intact; a string body + // would be re-encoded as UTF-8 and corrupt any byte > 0x7f. Cast because + // jest-fetch-mock types `body` as string, but undici accepts a Uint8Array. + body: (binaryBody ?? + (typeof mock.body === 'string' ? mock.body : JSON.stringify(mock.body))) as unknown as string, status: mock.status, headers: { - 'content-type': typeof mock.body === 'string' ? 'text/plain' : 'application/json', + 'content-type': binaryBody + ? 'image/png' + : typeof mock.body === 'string' + ? 'text/plain' + : 'application/json', }, }); });