## Goal

Stop wasting bandwidth on files that shouldn't upload, and extend the engine without forking it.
You'll configure **per-purpose validation rules**, add a custom **`validateFile`**, and write
**plugins** and **hooks**.

(size, type, ext, maxFiles)"]
  BATCH -->|fail| REJ["file.rejected(never enters state)"]
  BATCH -->|pass| STATE["enters state: validating"]
  STATE --> CHK["checksum + dedupe"]
  CHK --> CUSTOM["validateFile() + strict MIME"]
  CUSTOM -->|reason| ERR["phase: errorvalidation_failed"]
  CUSTOM -->|null| OK["phase: creating_intent"]`}
/>

Two validation layers exist, and the distinction matters:

* **Built-in rules** (size, type, extension, `maxFiles`) run synchronously in `addFiles`.
  Failures emit `file.rejected` and the file **never enters state** — listen via events.
* **`validateFile` and strict MIME** run after the file has entered (`validating` phase).
  Failures move the item to the `error` phase with `code: 'validation_failed'`.

## Configure validation

**Per-purpose rules**

Rules live in `config.validation`, keyed by purpose, typed as `Contracts.ValidationRules`.
First, grow the purpose union:

```typescript title="src/upload.ts"
type PhotoPurpose = 'photo' | 'avatar' | 'document'

export const store = createUploadStore<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>({
  api,
  strategies,
  config: {
    validation: {
      photo: {
        maxFiles: 20,
        maxSizeBytes: 10 * 1024 * 1024,
        allowedTypes: ['image/*'],
        allowedExtensions: ['jpg', 'jpeg', 'png', 'webp', 'heic'], // no leading dot
      },
      avatar: {
        maxFiles: 1,
        maxSizeBytes: 2 * 1024 * 1024,
        allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
      },
      document: {
        maxFiles: 10,
        maxSizeBytes: 50 * 1024 * 1024,
        allowedTypes: ['application/pdf'],
        allowedExtensions: ['pdf'],
      },
    },
  },
})
```

`Contracts.ValidationRules`:

```typescript
type ValidationRules = {
  maxFiles?: number
  maxSizeBytes?: number
  minSizeBytes?: number
  allowedTypes?: string[]      // MIME; 'image/*' wildcard prefix supported
  allowedExtensions?: string[] // extensions without a dot, case-insensitive
}
```

When both `allowedTypes` and `allowedExtensions` are set, a file passes if it matches
**either**.

**Show built-in rejections**

Built-in failures don't enter state, so listen for `file.rejected`. The reason is a
`Contracts.Validation.Rejection`:

```typescript
type Rejection =
  | { code: 'empty_file' }
  | { code: 'file_too_large'; maxBytes: number; size: number }
  | { code: 'type_not_allowed'; allowed: string[]; got: string }
  | { code: 'too_many_files'; max: number }
  | { code: 'mime_mismatch'; claimed: string; sniffed: string }
  | { code: 'filename_rejected'; reason: 'reserved' | 'too-long' | 'empty' | 'path-sep' }
```

```tsx title="src/RejectionToaster.tsx"
import { useEffect } from 'react'
import { useUploaderActions } from '@gentleduck/upload/react'
import type { Contracts } from '@gentleduck/upload/core'

function describe(reason: Contracts.Validation.Rejection): string {
  switch (reason.code) {
    case 'empty_file': return 'File is empty.'
    case 'file_too_large': return `Too large (${reason.size} B). Max ${reason.maxBytes} B.`
    case 'type_not_allowed': return `Type not allowed. Accepted: ${reason.allowed.join(', ')}.`
    case 'too_many_files': return `Too many files. Max ${reason.max}.`
    case 'mime_mismatch': return `Content looks like ${reason.sniffed}, not ${reason.claimed}.`
    case 'filename_rejected': return `Filename rejected (${reason.reason}).`
  }
}

export function RejectionToaster() {
  const { on } = useUploaderActions<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
  useEffect(() => on('file.rejected', ({ file, reason }) =>
    console.warn(file.name, describe(reason))
  ), [on])
  return null
}
```

**Custom validation with validateFile**

`validateFile` runs after built-in rules, once the file is in state. Return a
`Contracts.Validation.Rejection` to reject (moves the item to `error`), or `null` to accept:

```typescript title="src/upload.ts"
createUploadStore({
  api,
  strategies,
  config: { /* … */ },
  validateFile: (file, purpose) => {
    // Block suspicious executable double-extensions.
    const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
    if (['exe', 'bat', 'sh', 'cmd', 'msi'].includes(ext)) {
      return { code: 'type_not_allowed', allowed: [], got: file.name }
    }
    return null
  },
})
```

Items rejected here sit in `error` with `error.code === 'validation_failed'` and
`retryable: false`. Read them from state:

```tsx
const { items } = useUploader<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
const invalid = items.filter((i) => i.phase === 'error' && i.error.code === 'validation_failed')
```

**Write a plugin**

A plugin is an `Engine.Plugin` — a `name` plus `setup` receiving `{ on, off, dispatch,
    getSnapshot }`:

```typescript title="src/plugins/analytics.ts"
import type { Engine } from '@gentleduck/upload/core'

export const analyticsPlugin: Engine.Plugin<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult> = {
  name: 'analytics',
  setup({ on }) {
    on('upload.completed', ({ result, completedBy }) =>
      track('upload_completed', { fileId: result.fileId, completedBy }))
    on('upload.error', ({ error, retryable }) =>
      track('upload_error', { code: error.code, retryable }))
  },
}
```

`setup` gets:

| Method | Purpose |
| --- | --- |
| `on` / `off` | Subscribe/unsubscribe to events |
| `dispatch` | Send commands |
| `getSnapshot` | Read current state |

**Register plugins and hooks**

```typescript title="src/upload.ts"
import { analyticsPlugin } from './plugins/analytics'

createUploadStore({
  api,
  strategies,
  plugins: [analyticsPlugin],
  hooks: {
    onInternalEvent: (event, state) => {
      if (event.type === 'validation.failed') console.debug('[upload]', event)
    },
  },
})
```

| | Plugins | Hooks |
| --- | --- | --- |
| Subscribe to events | `on` | `onInternalEvent` |
| Dispatch commands | Yes | No |
| Read state | `getSnapshot` | receives `state` |
| Count | array | single object |
| Use for | extend behavior | observe / debug |

A practical plugin — auto-retry on rate limits using the server's suggested delay:

```typescript title="src/plugins/rate-limit-retry.ts"
import type { Engine } from '@gentleduck/upload/core'
import { UploadRateLimitError } from '@gentleduck/upload/core'

export const rateLimitRetry: Engine.Plugin<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult> = {
  name: 'rate-limit-retry',
  setup({ on, dispatch }) {
    on('upload.error', ({ localId, error, retryable }) => {
      if (error.code === 'rate_limit' && retryable) {
        const delay = error instanceof UploadRateLimitError ? error.retryAfterMs ?? 5000 : 5000
        setTimeout(() => dispatch({ type: 'retry', localId }), delay)
      }
    })
  },
}
```

## How the validation phase works

1. **Built-in batch** (`addFiles`): `maxFiles`, then per-file size/type/extension. Failures →
   `file.rejected`, never enter state.
2. **Checksum + dedupe**: if the fingerprint has a checksum and `findByChecksum` is implemented,
   a match completes the item via `dedupe.ok` (`completedBy: 'dedupe'`).
3. **`validateFile`**: your custom check.
4. **Strict MIME**: when `strictMimeMatch: true`, magic bytes are sniffed and mismatches rejected.
5. Steps 3–4 failures → `error` (`validation_failed`). Otherwise → `creating_intent`.

`maxFiles` counts existing non-canceled items for the same purpose. Use `remove` to free slots.

### MIME matching

| Pattern | Matches | Not |
| --- | --- | --- |
| `image/jpeg` | `image/jpeg` | `image/png` |
| `image/*` | `image/jpeg`, `image/png` | `video/mp4` |

`image/*` is a prefix match (`type.startsWith('image/')`), not a glob.

## Checkpoint

Full store config

```typescript
import { createUploadStore, IndexedDBAdapter } from '@gentleduck/upload/core'
import { analyticsPlugin } from './plugins/analytics'

export const store = createUploadStore<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>({
  api,
  strategies,
  config: {
    maxConcurrentUploads: 3,
    maxAttempts: 5,
    validation: {
      photo:    { maxFiles: 50, maxSizeBytes: 10 * 1024 * 1024, allowedTypes: ['image/*'] },
      avatar:   { maxFiles: 1,  maxSizeBytes: 2 * 1024 * 1024,  allowedTypes: ['image/jpeg', 'image/png', 'image/webp'] },
      document: { maxFiles: 10, maxSizeBytes: 50 * 1024 * 1024, allowedTypes: ['application/pdf'], allowedExtensions: ['pdf'] },
    },
    retryPolicy: ({ attempt, error }) => {
      if (error.code === 'auth' || error.code === 'validation_failed') return { retryable: false }
      return { retryable: true, delayMs: Math.min(1000 * 2 ** (attempt - 1), 30_000) }
    },
  },
  plugins: [analyticsPlugin],
  persistence: { key: 'photoduck-uploads', version: 1, adapter: IndexedDBAdapter },
  validateFile: (file) => {
    const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
    if (['exe', 'bat', 'sh', 'cmd', 'msi'].includes(ext)) {
      return { code: 'type_not_allowed', allowed: [], got: file.name }
    }
    return null
  },
})
```

***

## Chapter 7 FAQ

When does validation run?

Built-in size/type/count rules run synchronously in `addFiles` (rejections emit
`file.rejected` and never enter state). `validateFile` and strict MIME run in the async
`validating` phase; their failures land the item in `error`.

What if a purpose has no rules?

Built-in checks are skipped for that purpose; every file is accepted unless `validateFile`
rejects it. Define at least `maxSizeBytes` per purpose to enforce limits.

allowedTypes or allowedExtensions?

Use both for defense in depth (either match passes). Both are spoofable, so validate on the
server too. Extensions are matched without a leading dot.

Plugin or hook?

Hooks for passive observation (logging, devtools). Plugins when you need to react by
dispatching (auto-retry, auto-cleanup). Plugins get `dispatch`; hooks don't.

What if a plugin throws in setup?

It's caught and logged in development. The store keeps working and the remaining plugins
still initialize.

***

Next: [Chapter 8: Production Patterns](/duck-upload/course/chapter-8)