---
phase: 17-object-storage
verified: 2026-04-04T00:00:00Z
status: passed
score: 10/10 must-haves verified
re_verification: false
---

# Phase 17: Object Storage Verification Report

**Phase Goal:** Images are stored in and served from MinIO instead of the local filesystem
**Verified:** 2026-04-04
**Status:** PASSED
**Re-verification:** No — initial verification

---

## Goal Achievement

### Observable Truths

| #  | Truth                                                                                | Status     | Evidence                                                                  |
|----|--------------------------------------------------------------------------------------|------------|---------------------------------------------------------------------------|
| 1  | Storage service can upload a buffer to S3-compatible storage                         | VERIFIED   | `uploadImage` uses `PutObjectCommand` via `s3.send` in storage.service.ts |
| 2  | Storage service can delete an object from S3-compatible storage                      | VERIFIED   | `deleteImage` uses `DeleteObjectCommand` via `s3.send`                    |
| 3  | Storage service can generate a presigned URL for an object                           | VERIFIED   | `getImageUrl` uses `getSignedUrl` from `@aws-sdk/s3-request-presigner`    |
| 4  | Docker Compose starts MinIO with automatic bucket creation                           | VERIFIED   | Both compose files have `minio` + `minio-init` with `mc mb gearbox-images`|
| 5  | Image upload via POST /api/images stores file in MinIO, not local filesystem         | VERIFIED   | `imageRoutes` calls `uploadImage` from storage.service; no Bun.write      |
| 6  | Image upload via POST /api/images/from-url stores fetched image in MinIO             | VERIFIED   | `fetchImageFromUrl` calls `uploadImage` from storage.service; no Bun.write|
| 7  | Deleting an item or candidate with an image deletes the image from MinIO             | VERIFIED   | items.ts and threads.ts both call `deleteImage(deleted.imageFilename)`    |
| 8  | API responses include imageUrl field with presigned URLs for items and candidates    | VERIFIED   | items.ts, threads.ts, setups.ts all call `withImageUrl`/`withImageUrls`   |
| 9  | Static file serving for /uploads/* is removed from the server                       | VERIFIED   | No `/uploads/` route in index.ts; only SPA `serveStatic` remains          |
| 10 | Client components display images using presigned URLs from API responses             | VERIFIED   | ItemCard, CandidateCard, CandidateListItem, ComparisonTable, ImageUpload all use `imageUrl` prop; zero `/uploads/` path construction in client |

**Score:** 10/10 truths verified

---

### Required Artifacts

| Artifact                                        | Expected                                        | Status    | Details                                                                 |
|-------------------------------------------------|-------------------------------------------------|-----------|-------------------------------------------------------------------------|
| `src/server/services/storage.service.ts`        | S3 storage abstraction                          | VERIFIED  | Exports `uploadImage`, `deleteImage`, `getImageUrl`, `withImageUrl`, `withImageUrls`; `forcePathStyle: true` |
| `tests/services/storage.service.test.ts`        | Storage service unit tests with mocked S3Client | VERIFIED  | 8 tests, all pass; mocks S3Client.send and getSignedUrl                 |
| `docker-compose.dev.yml`                        | MinIO service for local development             | VERIFIED  | Uses `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`, `minio-init` creates `gearbox-images` bucket |
| `docker-compose.yml`                            | MinIO service for production                    | VERIFIED  | Same image, env var creds, S3 vars injected into app service, no uploads volume |
| `src/server/services/image.service.ts`          | URL-based image fetch using storage service     | VERIFIED  | Imports and calls `uploadImage`; no `Bun.write` or `mkdir`              |
| `src/server/routes/images.ts`                   | Image upload routes using storage service       | VERIFIED  | POST `/` calls `uploadImage`; no local filesystem writes                |
| `src/server/index.ts`                           | Server entry without /uploads/* static serving  | VERIFIED  | Only SPA `serveStatic` present; no `/uploads/` route                    |
| `src/client/components/ItemCard.tsx`            | Item display using imageUrl from API            | VERIFIED  | Accepts `imageUrl` prop; renders `<img src={imageUrl}>` when truthy     |
| `src/client/components/CandidateCard.tsx`       | Candidate display using imageUrl from API       | VERIFIED  | Accepts `imageUrl` prop; renders `<img src={imageUrl}>` when truthy     |
| `src/client/components/ImageUpload.tsx`         | Upload component with presigned URL support     | VERIFIED  | Accepts `imageUrl` prop; priority: localPreview > imageUrl > null       |
| `scripts/migrate-images-to-minio.ts`            | One-time migration of local images to MinIO     | VERIFIED  | Reads uploads/, calls `uploadImage` per file, preserves filenames, no auto-delete |
| `tests/services/image.service.test.ts`          | Image service tests with mocked storage         | VERIFIED  | 5 tests, all pass; mocks storage.service                                |
| `tests/routes/images.test.ts`                   | Image routes tests with mocked storage          | VERIFIED  | 5 tests, all pass; verifies `mockUploadImage` is called on valid upload  |

---

### Key Link Verification

| From                                           | To                                  | Via                                      | Status    | Details                                                              |
|------------------------------------------------|-------------------------------------|------------------------------------------|-----------|----------------------------------------------------------------------|
| `src/server/services/storage.service.ts`       | `@aws-sdk/client-s3`                | S3Client with `forcePathStyle: true`     | WIRED     | Line 20: `forcePathStyle: true`                                      |
| `docker-compose.dev.yml`                       | `minio-init`                        | `mc mb --ignore-existing myminio/gearbox-images` | WIRED | Lines 61-62 confirmed                                           |
| `src/server/routes/images.ts`                  | `src/server/services/storage.service.ts` | `uploadImage()` call              | WIRED     | Line 6: `import { uploadImage } from "../services/storage.service"` |
| `src/server/routes/items.ts`                   | `src/server/services/storage.service.ts` | `deleteImage`, `withImageUrl`, `withImageUrls` | WIRED | Lines 15-18: all three imported and used                      |
| `src/server/routes/threads.ts`                 | `src/server/services/storage.service.ts` | `deleteImage`, `withImageUrls`   | WIRED     | Lines 13-15: imported and used in GET /:id and DELETE handlers      |
| `src/client/components/ItemCard.tsx`           | API response                        | `imageUrl` prop instead of `/uploads/` path | WIRED | Line 154: `{imageUrl ? <img src={imageUrl}>` renders presigned URL |
| `scripts/migrate-images-to-minio.ts`           | `src/server/services/storage.service.ts` | `uploadImage()` for each file    | WIRED     | Line 18: `import { uploadImage }` from storage.service; Line 64: called per file |

---

### Data-Flow Trace (Level 4)

| Artifact                         | Data Variable  | Source                            | Produces Real Data | Status   |
|----------------------------------|----------------|-----------------------------------|--------------------|----------|
| `src/server/routes/items.ts`     | `imageUrl`     | `withImageUrls(items)` → `getImageUrl()` → `getSignedUrl()` | Yes — calls AWS SDK getSignedUrl with actual S3 command | FLOWING |
| `src/client/components/ItemCard.tsx` | `imageUrl` | Passed as prop from `CollectionView.tsx` (line 231) which receives from React Query `useItems` | Yes — flows from API GET /api/items which calls withImageUrls | FLOWING |
| `src/client/components/ImageUpload.tsx` | `displayUrl` | `localPreview || imageUrl || null` — imageUrl from parent form props | Yes — ItemForm passes `items?.find(...)?.imageUrl` from query cache | FLOWING |

---

### Behavioral Spot-Checks

| Behavior                                              | Command                                                                                     | Result                 | Status |
|-------------------------------------------------------|---------------------------------------------------------------------------------------------|------------------------|--------|
| Storage service unit tests pass                       | `bun test tests/services/storage.service.test.ts --timeout 30000`                          | 8 pass, 0 fail         | PASS   |
| Image service unit tests pass                         | `bun test tests/services/image.service.test.ts --timeout 30000`                            | 5 pass, 0 fail         | PASS   |
| Image routes tests pass                               | `bun test tests/routes/images.test.ts --timeout 30000`                                     | 5 pass, 0 fail (exit 99 is PGlite teardown, not a test failure) | PASS |
| No /uploads/ references in server source              | `grep -rn "/uploads/" src/server/` (excluding tests)                                       | 0 matches              | PASS   |
| No /uploads/ references in client source              | `grep -rn "/uploads/" src/client/`                                                         | 0 matches              | PASS   |
| No Bun.write/unlink uploads in server                 | `grep -rn "Bun\.write\|unlink.*uploads" src/server/`                                       | 0 matches              | PASS   |
| Migration script imports uploadImage and reads files  | `grep -q "uploadImage\|readdir" scripts/migrate-images-to-minio.ts`                        | Both present           | PASS   |
| Migration script does not auto-delete originals       | `grep -q "unlink\|rm -rf" scripts/migrate-images-to-minio.ts`                             | Not present            | PASS   |

---

### Requirements Coverage

| Requirement | Source Plan(s) | Description                                                         | Status    | Evidence                                                                      |
|-------------|----------------|---------------------------------------------------------------------|-----------|-------------------------------------------------------------------------------|
| IMG-01      | 17-01, 17-02   | Images are stored in MinIO (S3-compatible) instead of local filesystem | SATISFIED | uploadImage via @aws-sdk/client-s3 in storage.service.ts; no local writes remain |
| IMG-02      | 17-03          | Existing uploaded images are migrated to MinIO                      | SATISFIED | `scripts/migrate-images-to-minio.ts` reads uploads/, calls uploadImage per file |
| IMG-03      | 17-02, 17-03   | Image upload and retrieval work through the new storage layer       | SATISFIED | All upload routes call uploadImage; all GET responses call withImageUrl(s)    |
| IMG-04      | 17-01          | Docker Compose provides MinIO for local development                 | SATISFIED | docker-compose.dev.yml has minio service + minio-init bucket creation; docker-compose.yml has same for production |

All 4 requirements SATISFIED. No orphaned requirements.

---

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| None | —    | —       | —        | —      |

No TODO, FIXME, placeholder, stub returns, or empty handlers found in any phase 17 files.

---

### Human Verification Required

#### 1. MinIO Container Connectivity

**Test:** Run `docker compose -f docker-compose.dev.yml up minio minio-init` and verify the `gearbox-images` bucket is created automatically.
**Expected:** `minio-init` container exits 0, MinIO console at `http://localhost:9001` shows `gearbox-images` bucket.
**Why human:** Requires Docker daemon and network connectivity — cannot verify programmatically without running services.

#### 2. End-to-End Image Upload and Display

**Test:** Start the dev stack with MinIO running. Log in, create an item, upload a photo via the UI. Verify the image displays correctly on the item card.
**Expected:** Image renders in ItemCard via a presigned S3 URL (URL starts with `http://localhost:9000/gearbox-images/...?X-Amz-Signature=...`). No `/uploads/` paths in network requests.
**Why human:** Requires running server + MinIO + browser — cannot assert presigned URL format or image rendering programmatically.

#### 3. Presigned URL Expiry

**Test:** Verify a presigned URL returned by GET /api/items includes `X-Amz-Expires=3600` (or the value set in `S3_PRESIGN_EXPIRY`).
**Expected:** URL contains `X-Amz-Expires=3600` by default, and respects the env var override.
**Why human:** Requires a live MinIO instance to generate real presigned URLs.

---

### Gaps Summary

No gaps. All 10 observable truths are VERIFIED. All 13 artifacts exist, are substantive, and are properly wired. All 4 required image requirements (IMG-01 through IMG-04) are satisfied. All test suites pass. Zero `/uploads/` or local filesystem write references remain in client or server source code.

---

_Verified: 2026-04-04_
_Verifier: Claude (gsd-verifier)_
