---
phase: 05-files
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - backend/go.mod
  - backend/go.sum
  - backend/migrations/0005_files.sql
  - backend/internal/db/queries/files.sql
  - backend/internal/db/sqlc/files.sql.go
  - backend/internal/db/sqlc/models.go
  - backend/internal/files/store.go
  - backend/internal/web/handlers_files_test.go
  - backend/compose.yaml
  - backend/internal/files/doc.go
autonomous: true
requirements:
  - FILE-01
  - FILE-02
  - FILE-03
  - FILE-04
  - FILE-05
  - FILE-06

must_haves:
  truths:
    - "go build ./... compiles after aws-sdk-go-v2 modules added"
    - "tablo_files table exists after goose migrate up"
    - "files.Store has Upload, Delete, PresignDownload methods satisfying FileStorer interface"
    - "handlers_files_test.go contains RED test stubs for FILE-01..FILE-06 (compile but skip)"
    - "MinIO service runs in compose.yaml with mc init container creating xtablo-dev bucket"
    - "D-03: compose.yaml contains minio and minio-init services; minio-init creates xtablo-dev bucket with restart: no"
    - "D-05: files.Store.Upload calls http.DetectContentType on first 512 bytes to detect content-type server-side"
  artifacts:
    - path: "backend/migrations/0005_files.sql"
      provides: "tablo_files schema"
      contains: "CREATE TABLE tablo_files"
    - path: "backend/internal/db/queries/files.sql"
      provides: "sqlc query definitions"
      contains: "InsertTabloFile"
    - path: "backend/internal/files/store.go"
      provides: "S3 client + FileStorer interface"
      exports: ["NewStore", "FileStorer", "Store"]
    - path: "backend/internal/web/handlers_files_test.go"
      provides: "RED test scaffold"
      contains: "TestFileUpload"
    - path: "backend/compose.yaml"
      provides: "MinIO local dev service"
      contains: "minio/minio"
  key_links:
    - from: "backend/internal/files/store.go"
      to: "aws-sdk-go-v2/service/s3"
      via: "go get + import"
      pattern: "s3\\.NewFromConfig"
    - from: "backend/internal/db/sqlc/files.sql.go"
      to: "backend/migrations/0005_files.sql"
      via: "sqlc generate"
      pattern: "InsertTabloFile"
---

<objective>
Wave 0 foundation for Phase 5. Adds the four aws-sdk-go-v2 modules, the 0005_files.sql migration, sqlc query file + regeneration, the internal/files package (Store + FileStorer interface), a RED test scaffold, and MinIO in compose.yaml.

Purpose: All later plans in this phase depend on these artifacts compiling. Nothing is wired to the HTTP server yet — that is Wave 1 and 2.
Output: Compiling S3 client package, sqlc-generated models with TabloFile struct, RED test stubs for FILE-01..06, MinIO available via compose.
</objective>

<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/PROJECT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-PATTERNS.md

<interfaces>
<!-- Key types the executor needs. Extracted from codebase. -->

From backend/internal/db/queries/tasks.sql (sqlc style reference):
  -- name: InsertTask :one
  INSERT INTO tasks (tablo_id, title, ...) VALUES ($1, $2, ...) RETURNING id, ...;
  -- name: ListTasksByTablo :many
  SELECT ... FROM tasks WHERE tablo_id = $1 ORDER BY status, position, created_at;
  -- name: GetTaskByID :one
  SELECT ... FROM tasks WHERE id = $1 AND tablo_id = $2;
  -- name: DeleteTask :exec
  DELETE FROM tasks WHERE id = $1 AND tablo_id = $2;

From backend/migrations/0004_tasks.sql (migration style reference):
  -- +goose Up
  CREATE TABLE tasks ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tablo_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, ... );
  CREATE INDEX tasks_tablo_id_status_idx ON tasks(tablo_id, status, position);
  -- +goose Down
  DROP TABLE IF EXISTS tasks;

From backend/internal/files/doc.go (current placeholder):
  // Package files is a Phase 1 placeholder; the upload/storage implementation lands in Phase 5.
  package files

From backend/internal/web/handlers_tasks.go (TasksDeps pattern to mirror):
  type TasksDeps struct {
    Queries *sqlc.Queries
  }

From backend/internal/web/router.go (current NewRouter signature):
  func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: aws-sdk-go-v2 modules + migration + sqlc queries + files.Store</name>
  <files>
    backend/go.mod
    backend/go.sum
    backend/migrations/0005_files.sql
    backend/internal/db/queries/files.sql
    backend/internal/db/sqlc/files.sql.go
    backend/internal/db/sqlc/models.go
    backend/internal/files/store.go
    backend/internal/files/doc.go
  </files>
  <read_first>
    backend/go.mod — current module deps; confirm aws-sdk-go-v2 absent before go get
    backend/migrations/0004_tasks.sql — exact migration format to replicate (goose markers, CASCADE, timestamptz, IF NOT EXISTS)
    backend/internal/db/queries/tasks.sql — sqlc annotation format (:one, :many, :exec) and column list style
    backend/internal/files/doc.go — placeholder to replace with real package body
    .planning/phases/05-files/05-RESEARCH.md §Pattern 1, Pattern 2, Pattern 3 — Store struct, Upload (sniff+stream), PresignDownload
    .planning/phases/05-files/05-PATTERNS.md §backend/internal/files/store.go — full method signatures including FileStorer interface
  </read_first>
  <action>
    Step 1 — Add aws-sdk-go-v2 modules: run inside backend/ directory:
      go get github.com/aws/aws-sdk-go-v2@latest
      go get github.com/aws/aws-sdk-go-v2/config@latest
      go get github.com/aws/aws-sdk-go-v2/credentials@latest
      go get github.com/aws/aws-sdk-go-v2/service/s3@latest
    Verify go.mod contains all four entries after go get.

    Step 2 — Write backend/migrations/0005_files.sql. Follow 0004_tasks.sql format exactly:
      Header comment line: -- migrations/0005_files.sql
      Phase description comment
      -- +goose Up block with:
        CREATE TABLE tablo_files (
          id           uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
          tablo_id     uuid        NOT NULL REFERENCES tablos(id) ON DELETE CASCADE,
          s3_key       text        NOT NULL,
          filename     text        NOT NULL,
          content_type text        NOT NULL DEFAULT 'application/octet-stream',
          size_bytes   bigint      NOT NULL DEFAULT 0,
          created_at   timestamptz NOT NULL DEFAULT now()
        );
        CREATE INDEX tablo_files_tablo_id_idx ON tablo_files(tablo_id, created_at DESC);
      -- +goose Down block: DROP TABLE IF EXISTS tablo_files; (no DROP TYPE — no custom ENUM)
      No updated_at column (files are immutable per D-06). bigint for size_bytes (not integer).

    Step 3 — Write backend/internal/db/queries/files.sql with four queries following tasks.sql annotation style:
      InsertTabloFile :one — INSERT INTO tablo_files (tablo_id, s3_key, filename, content_type, size_bytes) VALUES ($1,$2,$3,$4,$5) RETURNING all columns
      ListFilesByTablo :many — SELECT all columns FROM tablo_files WHERE tablo_id = $1 ORDER BY created_at DESC
      GetTabloFileByID :one — SELECT all columns FROM tablo_files WHERE id = $1 AND tablo_id = $2
      DeleteTabloFile :exec — DELETE FROM tablo_files WHERE id = $1 AND tablo_id = $2

    Step 4 — Run sqlc generate (from backend/ directory: sqlc generate or just sqlc). Confirm files.sql.go and updated models.go exist in backend/internal/db/sqlc/. models.go must contain TabloFile struct with fields matching migration columns.

    Step 5 — Replace backend/internal/files/doc.go body with full store.go implementation. Replace the placeholder doc.go content; the file stays as doc.go but gains the full package body OR create store.go alongside doc.go. Keep doc.go as the package doc comment only; create store.go for the implementation. store.go must contain:
      - FileStorer interface with three methods: Upload(ctx, key string, file io.Reader) (contentType string, bytesWritten int64, err error), Delete(ctx, key string) error, PresignDownload(ctx, key string) (string, error)
      - Store struct with client *s3.Client and bucket string fields
      - NewStore(ctx context.Context, endpoint, bucket, region, accessKey, secretKey string, usePathStyle bool) (*Store, error) — uses config.LoadDefaultConfig with credentials.NewStaticCredentialsProvider; sets o.BaseEndpoint = aws.String(endpoint) and o.UsePathStyle = usePathStyle on client options (per D-02, Pitfall 1 in RESEARCH — MinIO requires UsePathStyle: true)
      - Upload method: (1) r.Body = http.MaxBytesReader is NOT called here — that's the handler's job; (2) read first 512 bytes with io.ReadFull, accept io.ErrUnexpectedEOF as non-fatal (Pitfall 3); (3) http.DetectContentType(sniffBuf[:n]) for content-type (per D-05); (4) wrap sniff bytes + remaining reader in a byteCountReader (simple struct wrapping io.Reader, incrementing a counter on each Read call) + io.MultiReader(sniffBytes, file) (Pitfall 8 — header.Size unreliable); (5) call s.client.PutObject with Bucket, Key, Body (counting reader wrapping MultiReader), ContentType; (6) return contentType, bytesWritten from counter, err
      - Delete method: calls s.client.DeleteObject with Bucket and Key, returns error
      - PresignDownload method: creates s3.NewPresignClient(s.client), calls PresignGetObject with 5*time.Minute Expires option, returns req.URL

    Verify: cd backend && go build ./internal/files/... must succeed (no compile errors).
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./internal/files/... && go build ./internal/db/... && echo "BUILD OK"</automated>
  </verify>
  <done>
    go build ./internal/files/... exits 0. go.mod contains four aws-sdk-go-v2 entries. backend/migrations/0005_files.sql contains CREATE TABLE tablo_files. backend/internal/db/sqlc/ contains files.sql.go with InsertTabloFile method. FileStorer interface exported from store.go with Upload, Delete, PresignDownload signatures.
  </done>
  <acceptance_criteria>
    - go build ./internal/files/... exits 0 (no compile errors)
    - go.mod contains line matching "github.com/aws/aws-sdk-go-v2/service/s3"
    - backend/migrations/0005_files.sql contains "CREATE TABLE tablo_files" and "ON DELETE CASCADE" and "-- +goose Up" and "-- +goose Down"
    - backend/internal/db/sqlc/files.sql.go contains function "InsertTabloFile" and "ListFilesByTablo" and "GetTabloFileByID" and "DeleteTabloFile"
    - backend/internal/files/store.go contains "type FileStorer interface" and "func NewStore(" and "func (s *Store) Upload(" and "func (s *Store) Delete(" and "func (s *Store) PresignDownload("
    - store.go contains "io.ErrUnexpectedEOF" (Pitfall 3 guard) and "UsePathStyle" (Pitfall 1 guard)
  </acceptance_criteria>
</task>

<task type="auto">
  <name>Task 2: RED test scaffold + MinIO in compose.yaml + .env.example additions</name>
  <files>
    backend/internal/web/handlers_files_test.go
    backend/compose.yaml
    backend/internal/files/store_test.go
  </files>
  <read_first>
    backend/internal/web/handlers_tasks_test.go — exact test file structure, setupTestDB pattern, stubbed handler test shape, t.Skip pattern for missing infrastructure
    backend/compose.yaml — current file content (postgres service + volumes block to extend)
    .planning/phases/05-files/05-RESEARCH.md §Validation Architecture — test names FILE-01..06, skip pattern for missing S3_ENDPOINT
    .planning/phases/05-files/05-PATTERNS.md §compose.yaml — MinIO services block (Pattern 7) verbatim
  </read_first>
  <action>
    Step 1 — Create backend/internal/web/handlers_files_test.go with RED stubs. Follow handlers_tasks_test.go exactly for package declaration, imports, and setupTestDB reuse. Add test functions that t.Skip("FILE handler tests: not yet implemented") and will be un-skipped in Plan 02/03:
      TestFileUpload (FILE-01, FILE-02) — stub
      TestFilesList (FILE-03) — stub
      TestFileDownload (FILE-04) — stub
      TestFileDelete (FILE-05) — stub
      TestFileOwnership (FILE-06) — stub; verifies non-owner gets 404 on GET/POST /tablos/{id}/files and file sub-routes

    Also declare FileStorer test stub at top of file (a stubbedFileStorer implementing FileStorer interface with no-op methods) so Plan 02/03 can inject it without creating a new file.

    Step 2 — Create backend/internal/files/store_test.go with placeholder that verifies store.go compiles and interface is satisfied:
      TestStoreImplementsFileStorer — compile-time assertion: var _ FileStorer = (*Store)(nil)
      TestNewStore_SkipIfNoEndpoint — reads S3_ENDPOINT env var; t.Skip if empty; if set, calls NewStore and verifies non-nil client returned

    Step 3 — Update backend/compose.yaml: add the minio and minio-init services after the postgres service, following Pattern 7 from RESEARCH.md exactly:
      minio service: image minio/minio:latest, container_name xtablo-backend-minio, restart unless-stopped, environment MINIO_ROOT_USER/MINIO_ROOT_PASSWORD both "minioadmin", ports 9000:9000 and 9001:9001, command "server /data --console-address :9001", volumes minio_data:/data, healthcheck using "mc ready local" with interval 5s / timeout 5s / retries 10
      minio-init service: image minio/mc:latest, depends_on minio condition service_healthy, entrypoint runs mc alias set local http://minio:9000 minioadmin minioadmin then mc mb --ignore-existing local/xtablo-dev, restart "no" (NOT unless-stopped — Pitfall 7)
      Add minio_data: entry to volumes block alongside postgres_data:

    Do NOT add .env.example in this task — that file does not yet exist in backend/. Check with ls backend/*.example before touching. If backend/.env.example exists, add S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, S3_USE_PATH_STYLE, MAX_UPLOAD_SIZE_MB entries per RESEARCH.md §.env.example additions. If it does not exist, skip.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./... && go test ./internal/web/ -run TestFile -v -count=1 2>&1 | grep -E "SKIP|PASS|FAIL|ok"</automated>
  </verify>
  <done>
    go build ./... exits 0. TestFile* tests compile and all SKIP (not FAIL). compose.yaml contains minio and minio-init services. minio-init has restart: "no". volumes block contains minio_data:.
  </done>
  <acceptance_criteria>
    - go build ./... exits 0
    - go test ./internal/web/ -run TestFile -v exits 0 with all tests showing "--- SKIP" (zero FAIL)
    - backend/compose.yaml contains "minio/minio" and "minio/mc" and "minio_data:" and 'restart: "no"'
    - backend/internal/web/handlers_files_test.go contains "TestFileUpload" and "TestFilesList" and "TestFileDownload" and "TestFileDelete" and "TestFileOwnership"
    - backend/internal/files/store_test.go contains "TestStoreImplementsFileStorer" and "var _ FileStorer = (*Store)(nil)"
  </acceptance_criteria>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Go module fetch | aws-sdk-go-v2 fetched from Go proxy — verify module paths match expected (no typosquatting) |
| compose.yaml MinIO credentials | minioadmin/minioadmin are dev-only credentials; production uses env-injected S3_ACCESS_KEY/S3_SECRET_KEY |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-W0-01 | Tampering | go.mod | mitigate | Use official module paths github.com/aws/aws-sdk-go-v2/{config,credentials,service/s3} — verify paths in go.mod after go get |
| T-05-W0-02 | Information Disclosure | compose.yaml MinIO credentials | accept | minioadmin/minioadmin are well-known dev defaults; no production secret exposure; production uses real env vars |
| T-05-W0-03 | Elevation of Privilege | FileStorer interface | mitigate | Interface defined in internal/files package; only web handlers in same module can construct the concrete Store — no external injection path |
</threat_model>

<verification>
After both tasks:
- cd backend && go build ./... exits 0
- cd backend && go test ./internal/files/... -v -count=1 exits 0 (TestStoreImplementsFileStorer PASS, TestNewStore_SkipIfNoEndpoint SKIP)
- cd backend && go test ./internal/web/ -run TestFile -v -count=1 exits 0 (all SKIP)
- compose.yaml contains both minio services
- backend/migrations/0005_files.sql exists and contains correct goose markers
</verification>

<success_criteria>
1. go build ./... succeeds from backend/ directory
2. Six TestFile* stubs exist in handlers_files_test.go, all SKIP when run
3. 0005_files.sql migration has correct schema (tablo_files table, ON DELETE CASCADE, timestamptz, bigint size_bytes, composite index)
4. files.Store implements FileStorer interface (compile-time verified in store_test.go)
5. compose.yaml has MinIO service on port 9000 + mc init container with restart: "no"
</success_criteria>

<output>
After completion, create .planning/phases/05-files/05-01-SUMMARY.md
</output>
