diff --git a/apps/api/src/modules/inventory/domain/inventory-image.js b/apps/api/src/modules/inventory/domain/inventory-image.js new file mode 100644 index 0000000..6e16d41 --- /dev/null +++ b/apps/api/src/modules/inventory/domain/inventory-image.js @@ -0,0 +1,31 @@ +import { createApplicationError } from '../../../shared/errors/application-error.js'; + +const SUPPORTED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); +const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; +const DEFAULT_MAX_DIMENSION = 10_000; + +export function validateInventoryImageMetadata( + image, + { maxFileSize = DEFAULT_MAX_FILE_SIZE, maxDimension = DEFAULT_MAX_DIMENSION } = {}, +) { + if (!SUPPORTED_MIME_TYPES.has(image.mimeType)) { + throw createApplicationError('IMAGE_MIME_TYPE_UNSUPPORTED', 'Image MIME type is not supported.', 400); + } + + if (!Number.isInteger(image.fileSize) || image.fileSize <= 0 || image.fileSize > maxFileSize) { + throw createApplicationError('IMAGE_FILE_TOO_LARGE', 'Image file size is invalid or too large.', 400); + } + + if ( + !Number.isInteger(image.width) || + !Number.isInteger(image.height) || + image.width <= 0 || + image.height <= 0 || + image.width > maxDimension || + image.height > maxDimension + ) { + throw createApplicationError('IMAGE_DIMENSIONS_INVALID', 'Image dimensions are invalid or too large.', 400); + } + + return true; +} \ No newline at end of file diff --git a/apps/api/src/modules/inventory/services/inventory-image-service.js b/apps/api/src/modules/inventory/services/inventory-image-service.js index 8332710..5ed9c42 100644 --- a/apps/api/src/modules/inventory/services/inventory-image-service.js +++ b/apps/api/src/modules/inventory/services/inventory-image-service.js @@ -1,8 +1,10 @@ import { createApplicationError } from '../../../shared/errors/application-error.js'; +import { validateInventoryImageMetadata } from '../domain/inventory-image.js'; export function createInventoryImageService({ inventoryImageRepository }) { return { async create(input) { + validateInventoryImageMetadata(input); const image = await inventoryImageRepository.createForSeller( { ...input, processingStatus: 'PENDING' }, input.sellerId, diff --git a/apps/api/tests/domain/inventory-image.test.js b/apps/api/tests/domain/inventory-image.test.js new file mode 100644 index 0000000..b15816c --- /dev/null +++ b/apps/api/tests/domain/inventory-image.test.js @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { validateInventoryImageMetadata } from '../../src/modules/inventory/domain/inventory-image.js'; + +const validImage = { + mimeType: 'image/jpeg', + fileSize: 12000, + width: 1200, + height: 900, +}; + +test('accepts supported image metadata within configured limits', () => { + assert.equal(validateInventoryImageMetadata(validImage), true); +}); + +test('rejects unsupported MIME types, oversized files, and oversized dimensions', () => { + assert.throws( + () => validateInventoryImageMetadata({ ...validImage, mimeType: 'image/gif' }), + (error) => error.code === 'IMAGE_MIME_TYPE_UNSUPPORTED', + ); + assert.throws( + () => validateInventoryImageMetadata({ ...validImage, fileSize: 11 * 1024 * 1024 }), + (error) => error.code === 'IMAGE_FILE_TOO_LARGE', + ); + assert.throws( + () => validateInventoryImageMetadata({ ...validImage, width: 10001 }), + (error) => error.code === 'IMAGE_DIMENSIONS_INVALID', + ); +}); \ No newline at end of file diff --git a/docs/IMAGES.md b/docs/IMAGES.md index 8928311..2edf302 100644 --- a/docs/IMAGES.md +++ b/docs/IMAGES.md @@ -53,6 +53,11 @@ Validate: - image dimensions - configured image count +The metadata service currently enforces JPEG, PNG, and WebP MIME types, a +10 MiB default file-size limit, and a 10,000-pixel maximum width and height. +Binary signature checks and multipart upload validation remain part of the +upload transport slice. + Generate: - thumbnail - medium diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 5b28f7b..0e9140d 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -91,6 +91,7 @@ docker context use orbstack - Listing reads are seller-scoped and return only admin listing DTO fields. - Item-owned image metadata can be added through a seller-scoped, CSRF-protected admin route; publication uses the persisted image count. - `ImageStorage` and a traversal-safe local filesystem adapter are implemented; upload transport, signature validation, and derivative processing remain. +- Image metadata limits are enforced in the service boundary: supported MIME types, 10 MiB default file size, and 10,000-pixel maximum dimensions. ### Verified Baseline @@ -101,13 +102,13 @@ docker context use orbstack | HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management | | Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling | -Latest result: `npm test` passed 62 tests; `npm run test:integration` passed 14 tests against OrbStack PostgreSQL and Redis. +Latest result: `npm test` passed 64 tests; `npm run test:integration` passed 14 tests against OrbStack PostgreSQL and Redis. ## Next Milestone Complete the inventory/listing integrity boundary before starting pricing and acquisition. -1. Add upload transport, MIME/signature/size validation, and processing/derivative lifecycle for persisted image metadata. +1. Add multipart upload transport, binary signature validation, and processing/derivative lifecycle for persisted image metadata. 2. Begin acquisition and pricing foundations after the listing/image boundary is complete. Acceptance criteria: listing reads and lifecycle writes preserve seller ownership, CSRF, inventory status, and publication prerequisites.