# Ticket 2.4 — PIN Authentication & Rate Limiting

**Phase**: 2 — Server & WebSocket Layer
**Priority**: High
**Depends on**: 2.1

## Description

Protect the relay with a 6-digit PIN. Rate-limit failed attempts to prevent brute force attacks. The PIN is required before accessing any project UI or WebSocket connection.

## Acceptance Criteria

### AC1: PIN protects all routes

```gherkin
Given a PIN has been set
When a browser requests any project route without a valid session cookie
Then it is redirected to the PIN entry page
And no project content is served
```

### AC2: Correct PIN grants access

```gherkin
Given the PIN is "123456"
When a browser POSTs to /auth with pin "123456"
Then it receives a session cookie
And it is redirected to the project UI
And subsequent requests with the cookie bypass the PIN page
```

### AC3: Incorrect PIN is rejected

```gherkin
Given the PIN is "123456"
When a browser POSTs to /auth with pin "000000"
Then it receives a 401 response
And the PIN entry page shows "Incorrect PIN"
And the failed attempt is recorded
```

### AC4: Rate limiting after failed attempts

```gherkin
Given 5 consecutive incorrect PIN attempts from the same IP
When the 6th attempt is made (even with the correct PIN)
Then it is rejected with "Too many attempts. Try again in 15 minutes."
And the lockout applies to that IP address
And the lockout expires after 15 minutes
```

### AC5: WebSocket connections require authentication

```gherkin
Given a PIN has been set
When a WebSocket connection is attempted without a valid session cookie
Then the upgrade is rejected with 401
And the connection is not established
```

### AC6: PIN can be updated via CLI

```gherkin
Given the relay daemon is running
When the user runs `conduit --pin 654321`
Then the PIN is updated to "654321"
And existing authenticated sessions remain valid
And new connections must use the new PIN
```

### AC7: No-PIN mode for trusted networks

```gherkin
Given no PIN has been set (--no-pin flag or first run)
When a browser accesses the relay
Then it is served the project UI directly without authentication
And a warning is logged: "No PIN set — relay is accessible to anyone on your network"
```

## Testable Components

| Component | Test Type | What to Assert |
|-----------|-----------|----------------|
| `authenticate(pin)` | Unit | Returns true for correct PIN, false for incorrect |
| `checkRateLimit(ip)` | Unit | Allows first 5 attempts, blocks 6th, resets after 15 min |
| `setPin(newPin)` | Unit | Updates PIN, validates 4-8 digit format |
| `validateSession(cookie)` | Unit | Returns true for valid cookie, false for expired/invalid |
| `createSession(pin)` | Unit | Returns cookie on correct PIN |
| Auth middleware | Fixture | Unauthenticated requests redirect, authenticated pass through |
| WebSocket auth | Fixture | Rejected without cookie, accepted with cookie |
| Rate limit reset | Unit | Counter resets after timeout period |

## API Shape

```ts
// src/lib/auth.ts
export class AuthManager {
  constructor(options = {})       // { maxAttempts: 5, lockoutMinutes: 15 }

  setPin(pin)                     // Set or update PIN
  getPin()                        // Get current PIN (for IPC)
  hasPin()                        // Boolean
  authenticate(pin, ip)           // Returns { ok, cookie? } or { ok: false, locked, retryAfter }
  validateCookie(cookie)          // Boolean
  isLocked(ip)                    // Check if IP is locked out
  middleware()                    // Express/http middleware function
  wsAuthCheck(request)            // Validate WebSocket upgrade request
}
```

## Reference

- Claude-relay PIN: `../claude-relay/lib/server.js` (lines 46-73, auth routes)
- Claude-relay rate limiting: 5 attempts, 15 minute lockout
- PIN stored in daemon config, not in project config
