diff --git a/skills/xapi-provider/SKILL.md b/skills/xapi-provider/SKILL.md index 898b742..c667c6d 100644 --- a/skills/xapi-provider/SKILL.md +++ b/skills/xapi-provider/SKILL.md @@ -58,6 +58,11 @@ scope. Do not try unrelated keys. - Before any create or publish: apply the [validation checklist](reference/validation-checklist.md). +For a local service LOGO, use `provider create --file ./service.json --logo-file +./logo.png` after checking command help. Read the local LOGO section in +[lifecycle management](reference/lifecycle-management.md) for limits, scope, +conflict rules and upload failure handling. + ## Standard lifecycle Prepare `service.json`, then create and inspect the draft: diff --git a/skills/xapi-provider/reference/lifecycle-management.md b/skills/xapi-provider/reference/lifecycle-management.md index 9191813..1ecc2a4 100644 --- a/skills/xapi-provider/reference/lifecycle-management.md +++ b/skills/xapi-provider/reference/lifecycle-management.md @@ -97,6 +97,36 @@ npx xapi-to provider create --file ./service.json The output contains the service and initial version/revision IDs. Keep those IDs for later commands. +### Upload a local LOGO when creating + +```bash +npx xapi-to provider create --file ./service.json --logo-file ./logo.png +``` + +`--logo-file` accepts JPG, PNG, GIF and WebP up to 10 MB. The CLI checks the +local file, uploads it using the same API Key with `service:create`, then sends +the returned remote URL as `logoUrl` in the create request. Storage credentials +remain on the server. Existing JSON `logoUrl` and creation without a LOGO work +as before. Remove a JSON `logoUrl` before passing `--logo-file`; supplying both +is an error. This flag is supported only by `provider create`. + +File, authentication, permission, network and storage errors stop creation and +return a nonzero exit code. Uploads are not automatically retried. Upload and +service creation are separate writes: if creation fails after upload, the image +may remain unused. Inspect `provider list` after an ambiguous create response +before retrying; do not automatically replay the create write. + +The installed CLI help must list `--logo-file`, and the selected server must +support provider LOGO uploads. A 404 requires a server update; 401 requires +checking the configured API Key; 403 requires `service:create` and allowed +source IPs. The server rechecks image content and enforces a 20/minute/IP limit. +A repository change does not imply that the CLI package or backend has already +been released. + +After successful creation, run `provider get ` and verify its +`logoUrl` matches the returned image URL; verify the image in the web console +when validating a deployed environment. + ## Service metadata versus version contract `provider update` changes marketplace metadata, the linked Skill, or service diff --git a/skills/xapi/guides/provider.md b/skills/xapi/guides/provider.md index 27f7d40..ed4a8fa 100644 --- a/skills/xapi/guides/provider.md +++ b/skills/xapi/guides/provider.md @@ -38,6 +38,36 @@ npx xapi-to provider list npx xapi-to provider get ``` +### Upload a local LOGO when creating + +```bash +npx xapi-to provider create --file ./service.json --logo-file ./logo.png +``` + +`--logo-file` accepts JPG, PNG, GIF and WebP up to 10 MB. The CLI checks the +local file, uploads it using the same API Key with `service:create`, then sends +the returned remote URL as `logoUrl` in the create request. Storage credentials +remain on the server. Existing JSON `logoUrl` and creation without a LOGO work +as before. Remove a JSON `logoUrl` before passing `--logo-file`; supplying both +is an error. This flag is supported only by `provider create`. + +File, authentication, permission, network and storage errors stop creation and +return a nonzero exit code. Uploads are not automatically retried. Upload and +service creation are separate writes: if creation fails after upload, the image +may remain unused. Inspect `provider list` after an ambiguous create response +before retrying; do not automatically replay the create write. + +The installed CLI help must list `--logo-file`, and the selected server must +support provider LOGO uploads. A 404 requires a server update; 401 requires +checking the configured API Key; 403 requires `service:create` and allowed +source IPs. The server rechecks image content and enforces a 20/minute/IP limit. +A repository change does not imply that the CLI package or backend has already +been released. + +After successful creation, run `provider get ` and verify its +`logoUrl` matches the returned image URL; verify the image in the web console +when validating a deployed environment. + Keep the three service content layers distinct: - `description` is the short marketplace-card summary. diff --git a/src/commands/provider.ts b/src/commands/provider.ts index 8a1bc64..4e35e26 100644 --- a/src/commands/provider.ts +++ b/src/commands/provider.ts @@ -4,6 +4,7 @@ import { mkdir, open, readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { apiKeyApiRequest, HttpError } from '../client.ts'; import { providerOnboarding, PROVIDER_ONBOARDING_HELP } from './provider-onboarding.ts'; +import { uploadProviderLogo } from '../provider-logo.ts'; import { redactProvider } from '../provider-client.ts'; import { getConfig, @@ -25,7 +26,7 @@ USAGE xapi-to provider wait --revision [--interval 2s] [--timeout 10m] xapi-to provider list xapi-to provider get [--version ] - xapi-to provider create --file [rate-limit flags] + xapi-to provider create --file [--logo-file ] [rate-limit flags] xapi-to provider update [metadata/rate-limit flags] xapi-to provider versions xapi-to provider version update --file [--replace] @@ -59,6 +60,10 @@ METADATA FLAGS --logo-url Service logo URL --category Marketplace category +CREATE LOGO + --logo-file Upload a local JPG, PNG, GIF or WebP (up to 10 MB) + Requires service:create; conflicts with JSON logoUrl + SERVICE RATE-LIMIT FLAGS --rate-limit-requests Allowed requests per period (1-1000000) --rate-limit-period-seconds Period length in seconds (1-86400) @@ -229,6 +234,9 @@ export async function provider(args: string[], flags: Record) { } const onboardingCommand = ['spec-rules', 'import', 'submit', 'wait'].includes(args[0]); const revisionAlias = ['update', 'review'].includes(args[0]) && flags.revision !== undefined; + if (flags['logo-file'] !== undefined && args[0] !== 'create') { + err('--logo-file is only supported by provider create'); + } if (onboardingCommand || revisionAlias) return providerOnboarding(args, flags); const cfg = getConfig(); requireApiKey(cfg); @@ -254,6 +262,13 @@ export async function provider(args: string[], flags: Record) { case 'create': { const body = await readJsonObject(required(flags.file, 'xapi-to provider create --file ')); applyRateLimitFlags(body, flags); + if (flags['logo-file'] !== undefined) { + const logoFile = requiredFlag(flags['logo-file'], 'xapi-to provider create --file --logo-file '); + if ((body.logoUrl !== undefined && body.logoUrl !== null && body.logoUrl !== '') || flags['logo-url'] !== undefined) { + err('--logo-file conflicts with logoUrl or --logo-url', 'Use either a local LOGO file or an existing URL.'); + } + body.logoUrl = await uploadProviderLogo(XAPI_API_HOST, apiKey, logoFile); + } result = await apiKeyApiRequest(XAPI_API_HOST, apiKey, `${BASE}/services`, { method: 'POST', body, diff --git a/src/provider-logo.ts b/src/provider-logo.ts new file mode 100644 index 0000000..49521cc --- /dev/null +++ b/src/provider-logo.ts @@ -0,0 +1,76 @@ +import { open } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { HttpError, uploadRequest } from './client.ts'; +import { scheme } from './config.ts'; + +const MAX_LOGO_SIZE = 10 * 1024 * 1024; + +function imageMime(bytes: Buffer): string | undefined { + if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'image/png'; + if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg'; + if (['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString('ascii'))) return 'image/gif'; + if (bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp'; +} + +async function readLogo(path: string): Promise<{ bytes: Buffer; mime: string }> { + let handle; + try { + handle = await open(path, 'r'); + const stat = await handle.stat(); + if (!stat.isFile()) throw new Error('LOGO must be a regular image file.'); + if (stat.size > MAX_LOGO_SIZE) throw new Error('LOGO exceeds the 10 MB limit.'); + // Bound the read even if the file grows after stat(). + const bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_LOGO_SIZE + 1)); + let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, null); + if (!bytesRead) break; + offset += bytesRead; + } + if (offset > MAX_LOGO_SIZE || offset > stat.size) throw new Error('LOGO changed while being read; retry with a stable file.'); + const content = bytes.subarray(0, offset); + const mime = imageMime(content); + if (!mime) throw new Error('Unsupported LOGO format. Use JPG, PNG, GIF or WebP.'); + return { bytes: content, mime }; + } catch (error: any) { + if (error?.code === 'ENOENT') throw new Error('LOGO file does not exist. Check --logo-file.'); + if (error?.code === 'EACCES' || error?.code === 'EPERM') throw new Error('LOGO file is not readable. Check file permissions.'); + if (error?.code) throw new Error('Cannot read LOGO file. Check --logo-file and file permissions.'); + throw error; + } finally { + await handle?.close(); + } +} + +/** Upload with the platform API Key; storage credentials stay on the server. */ +export async function uploadProviderLogo(apiHost: string, apiKey: string, path: string): Promise { + const { bytes, mime } = await readLogo(path); + const form = new FormData(); + form.set('file', new Blob([new Uint8Array(bytes)], { type: mime }), basename(path)); + let response: any; + try { + response = await uploadRequest(`${scheme(apiHost)}://${apiHost}/api/api-services/agent/logo`, { + method: 'POST', headers: { 'XAPI-KEY': apiKey }, body: form, + }); + } catch (error) { + if (error instanceof HttpError) { + const hint = error.status === 401 ? 'Check your API Key.' + : error.status === 403 ? 'Grant service:create and check API Key IP restrictions.' + : error.status === 404 ? 'The server must support provider LOGO uploads.' + : error.status === 413 ? 'Use an image up to 10 MB.' + : 'Check image format and server image storage configuration.'; + throw new Error(`LOGO upload failed (HTTP ${error.status}). ${hint} Service was not created.`); + } + // Avoid exposing a response body, URL or credentials from a transport error. + throw new Error('LOGO upload failed. Check network access and server image storage, then retry. Service was not created.'); + } + const url = response?.url ?? response?.data?.url; + try { + if (typeof url !== 'string') throw new Error(); + const parsed = new URL(url); + if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password) throw new Error(); + } catch { + throw new Error('LOGO upload returned no valid image URL. Service was not created.'); + } + return url; +} diff --git a/src/tests/provider-logo-http.test.ts b/src/tests/provider-logo-http.test.ts new file mode 100644 index 0000000..12291ba --- /dev/null +++ b/src/tests/provider-logo-http.test.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { mkdtemp, rm, writeFile, chmod } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jA7sAAAAASUVORK5CYII=', 'base64'); + +describe('provider LOGO CLI over HTTP', () => { + let dir: string; + let server: ReturnType; + let uploadStatus = 201; + let service: any; + let requests: string[]; + let uploaded: Uint8Array; + beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'xapi logo cli ')); + await writeFile(join(dir, 'service.json'), JSON.stringify({ name: 'Logo test', authType: 'HEADER' })); + await writeFile(join(dir, 'logo image.png'), PNG); + server = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(req) { + const path = new URL(req.url).pathname; + requests.push(`${req.method} ${path}`); + expect(req.headers.get('xapi-key')).toBe('local-test-key'); + if (path.endsWith('/logo')) { + const form = await req.formData(); + const file = form.get('file') as File; + expect(file.type).toBe('image/png'); + expect(file.name).toBe('logo image.png'); + uploaded = new Uint8Array(await file.arrayBuffer()); + return Response.json({ url: 'https://cdn.example.com/logos/local.png' }, { status: uploadStatus }); + } + if (req.method === 'POST') service = { ...await req.json(), id: 'service-1' }; + return Response.json(service); + } }); + }); + afterAll(async () => { server?.stop(true); await rm(dir, { recursive: true, force: true }); }); + async function cli(...args: string[]) { + const child = Bun.spawn([process.execPath, fileURLToPath(new URL('../index.ts', import.meta.url)), 'provider', ...args], { + env: { ...process.env, XAPI_KEY: 'local-test-key', XAPI_API_HOST: `127.0.0.1:${server.port}` }, stdout: 'pipe', stderr: 'pipe', + }); + const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + return { stdout, stderr, exit }; + } + it('sends exact multipart bytes, creates with the uploaded URL, then reads the same logoUrl', async () => { + requests = []; uploadStatus = 201; + const created = await cli('create', '--file', join(dir, 'service.json'), '--logo-file', join(dir, 'logo image.png')); + expect(created.exit).toBe(0); + expect(JSON.parse(created.stdout).logoUrl).toBe('https://cdn.example.com/logos/local.png'); + expect(Buffer.from(uploaded)).toEqual(PNG); + expect(requests).toEqual(['POST /api/api-services/agent/logo', 'POST /api/api-services/agent/services']); + const read = await cli('get', 'service-1'); + expect(read.exit).toBe(0); + expect(JSON.parse(read.stdout).logoUrl).toBe(service.logoUrl); + }); + it('exits nonzero on storage failure and never sends create or retries upload', async () => { + requests = []; uploadStatus = 500; + const result = await cli('create', '--file', join(dir, 'service.json'), '--logo-file', join(dir, 'logo image.png')); + expect(result.exit).not.toBe(0); + expect(result.stderr).toContain('LOGO upload failed (HTTP 500)'); + expect(requests).toEqual(['POST /api/api-services/agent/logo']); + }); + it('exits nonzero for an unreadable local file before contacting the API', async () => { + // Permission bits do not restrict root; the other validation tests still run there. + if (process.getuid?.() === 0) return; + requests = []; + const path = join(dir, 'private.png'); + await writeFile(path, PNG); await chmod(path, 0); + try { + const result = await cli('create', '--file', join(dir, 'service.json'), '--logo-file', path); + expect(result.exit).not.toBe(0); + expect(result.stderr).toContain('not readable'); + expect(requests).toEqual([]); + } finally { await chmod(path, 0o600); } + }); +}); diff --git a/src/tests/provider.test.ts b/src/tests/provider.test.ts index fcaba0d..d2d60db 100644 --- a/src/tests/provider.test.ts +++ b/src/tests/provider.test.ts @@ -8,6 +8,7 @@ import * as format from '../format.ts'; import { provider } from '../commands/provider.ts'; describe('provider command', () => { + let uploadSpy: ReturnType; let requestSpy: ReturnType; let outputSpy: ReturnType; let errSpy: ReturnType; @@ -15,6 +16,7 @@ describe('provider command', () => { const temporary: string[] = []; beforeEach(() => { + uploadSpy = spyOn(client, 'uploadRequest').mockResolvedValue({ url: 'https://cdn.example.com/logos/test.png' }); requestSpy = spyOn(client, 'apiKeyApiRequest').mockResolvedValue({ ok: true }); outputSpy = spyOn(format, 'output').mockImplementation(() => {}); errSpy = spyOn(format, 'err').mockImplementation((() => { throw new Error('err called'); }) as any); @@ -22,6 +24,7 @@ describe('provider command', () => { }); afterEach(async () => { + uploadSpy.mockRestore(); requestSpy.mockRestore(); outputSpy.mockRestore(); errSpy.mockRestore(); @@ -29,6 +32,112 @@ describe('provider command', () => { await Promise.all(temporary.splice(0).map((path) => rm(path, { recursive: true, force: true }))); }); + async function createFiles(body: Record = {}) { + const dir = await mkdtemp(join(tmpdir(), 'xapi-provider-logo-')); + temporary.push(dir); + const service = join(dir, 'service.json'); + const logo = join(dir, 'my logo.png'); + await writeFile(service, JSON.stringify({ name: 'Weather', ...body })); + await writeFile(logo, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jA7sAAAAASUVORK5CYII=', 'base64')); + return { dir, service, logo }; + } + + it('uploads binary multipart before creating with the returned remote URL', async () => { + const { service, logo } = await createFiles(); + uploadSpy.mockImplementationOnce(async () => { + expect(requestSpy).not.toHaveBeenCalled(); + return { url: 'https://cdn.example.com/logos/test.png' }; + }); + await provider(['create'], { file: service, 'logo-file': logo }); + const [url, init] = uploadSpy.mock.calls[0]; + expect(url).toBe('https://api.xapi.to/api/api-services/agent/logo'); + expect(init.headers).toEqual({ 'XAPI-KEY': 'sk-provider' }); + const image = init.body.get('file'); + expect(image.name).toBe('my logo.png'); + expect(image.type).toBe('image/png'); + expect(Buffer.from(await image.arrayBuffer())).toEqual(await readFile(logo)); + expect(requestSpy.mock.calls[0][3]).toEqual({ method: 'POST', body: { name: 'Weather', logoUrl: 'https://cdn.example.com/logos/test.png' } }); + expect(uploadSpy).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['image/jpeg', 'ffd8ffe000104a46494600010100000100010000ffd9'], + ['image/gif', '47494638396101000100800000000000ffffff2c00000000010001000002024401003b'], + ['image/webp', '524946461a000000574542505650384c0d0000002f00000010071011118888fe0700'], + ])('detects %s from file content rather than its name', async (mime, hex) => { + const { service, logo } = await createFiles(); + await writeFile(logo, Buffer.from(hex, 'hex')); + await provider(['create'], { file: service, 'logo-file': logo }); + expect(uploadSpy.mock.calls[0][1].body.get('file').type).toBe(mime); + expect(requestSpy.mock.calls[0][3].body.logoUrl).toBe('https://cdn.example.com/logos/test.png'); + }); + + it('accepts the exact 10 MB boundary', async () => { + const { service, logo } = await createFiles(); + const bytes = Buffer.alloc(10 * 1024 * 1024); + (await readFile(logo)).copy(bytes); + await writeFile(logo, bytes); + await provider(['create'], { file: service, 'logo-file': logo }); + expect(uploadSpy.mock.calls[0][1].body.get('file').size).toBe(bytes.length); + }); + + it.each([{}, { logoUrl: 'https://example.com/existing.png' }])('preserves JSON and skips upload when no local LOGO is supplied: %j', async (body) => { + const { service } = await createFiles(body); + await provider(['create'], { file: service }); + expect(uploadSpy).not.toHaveBeenCalled(); + expect(requestSpy.mock.calls[0][3].body).toEqual({ name: 'Weather', ...body }); + }); + + it('rejects conflicting URL or missing LOGO argument before any write', async () => { + const { service, logo } = await createFiles({ logoUrl: 'https://example.com/existing.png' }); + await expect(provider(['create'], { file: service, 'logo-file': logo })).rejects.toThrow(); + expect(errSpy.mock.calls[0][0]).toContain('conflicts'); + await expect(provider(['create'], { file: service, 'logo-file': 'true' })).rejects.toThrow(); + expect(uploadSpy).not.toHaveBeenCalled(); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it.each(['missing', 'directory', 'unsupported', 'oversized'])('rejects %s local images without a write', async (kind) => { + const { dir, service, logo } = await createFiles(); + if (kind === 'unsupported') await writeFile(logo, ''); + if (kind === 'oversized') await writeFile(logo, Buffer.alloc(10 * 1024 * 1024 + 1)); + const path = kind === 'missing' ? join(dir, 'missing.png') : kind === 'directory' ? dir : logo; + await expect(provider(['create'], { file: service, 'logo-file': path })).rejects.toThrow(); + expect(uploadSpy).not.toHaveBeenCalled(); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it.each([401, 403, 404, 400, 500])('stops creation on upload HTTP %s without echoing response secrets', async (status) => { + const { service, logo } = await createFiles(); + uploadSpy.mockRejectedValueOnce(new client.HttpError(status, 'sensitive-server-body')); + await expect(provider(['create'], { file: service, 'logo-file': logo })).rejects.toThrow(); + expect(requestSpy).not.toHaveBeenCalled(); + expect(JSON.stringify(errSpy.mock.calls)).toContain(`HTTP ${status}`); + expect(JSON.stringify(errSpy.mock.calls)).not.toContain('sensitive-server-body'); + }); + + it.each([{}, { url: '/local/image.png' }, { url: 'file:///tmp/logo.png' }, { url: 'data:image/png;base64,x' }])('rejects upload responses without a remote URL: %j', async (response) => { + const { service, logo } = await createFiles(); + uploadSpy.mockResolvedValueOnce(response); + await expect(provider(['create'], { file: service, 'logo-file': logo })).rejects.toThrow(); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('stops creation when the upload connection fails', async () => { + const { service, logo } = await createFiles(); + uploadSpy.mockRejectedValueOnce(new Error('network failure with sk-provider')); + await expect(provider(['create'], { file: service, 'logo-file': logo })).rejects.toThrow(); + expect(requestSpy).not.toHaveBeenCalled(); + expect(JSON.stringify(errSpy.mock.calls)).toContain('Check network'); + expect(JSON.stringify(errSpy.mock.calls)).not.toContain('sk-provider'); + }); + + it('rejects logo-file on update rather than silently ignoring it', async () => { + await expect(provider(['update', 'service-1'], { 'logo-file': './logo.png' })).rejects.toThrow(); + expect(requestSpy).not.toHaveBeenCalled(); + expect(uploadSpy).not.toHaveBeenCalled(); + }); + it('uploads About Markdown as service metadata without retrying the write', async () => { const dir = await mkdtemp(join(tmpdir(), 'xapi-provider-test-')); temporary.push(dir);