# Share Link Fix + PresharedKey Disable Feature

## TL;DR

> **Quick Summary**: Fix share link "always expired" bug (URL mismatch) + add peer-level PresharedKey disable toggle with server default setting + fix device-agent dropped PresharedKey.
>
> **Deliverables**:
> - Share link fix: `ShareConfig.vue` — use `fetch()` at root path, not Axios
> - PSK disable: Device model `DisablePresharedKey bool`, WgServer model `DefaultDisablePresharedKey bool` + all creation/generation paths + frontend toggles
> - Agent PSK: `WireGuardConfig.PresharedKey` field + UAPI emission
>
> **Estimated Effort**: Medium (8 files backend, 4 files frontend, 2 files agent)
> **Parallel Execution**: YES — 3 waves
> **Critical Path**: Model migration → Backend handlers → Frontend toggles → Agent structs → Final QA

---

## Context

### Original Request
1. **Bug**: Share config link selalu expired — recipient always sees "Link Expired or Invalid"
2. **Feature**: Peer dapat disable PresharedKey; server default disable PSK untuk peer baru

### Metis Analysis Key Findings
- **Share bug root cause**: URL mismatch — `ShareConfig.vue` calls `api.get('/share/:token')` which adds `/api/v1` prefix via Axios baseURL, but server route is at root `GET /share/:token`
- **Share bug secondary concern**: Server returns generic 404 for both expired AND invalid — error message doesn't distinguish
- **PSK disable scope**: 3 creation paths (peer, device, provisioning) + 3 config generators (peer config, share config, provisioning response) + agent UAPI
- **Precedence**: `device.DisablePresharedKey` > `server.DefaultDisablePresharedKey` > `false` (keep PSK)
- **Migration**: GORM AutoMigrate with `default:false` tags; existing rows get `false`
- **Agent**: Missing `PresharedKey` field in `WireGuardConfig` struct; needs to handle empty gracefully

### Current State
- **Share link**: `PeerConfigModal.vue` creates share at admin dashboard → generates URL like `/share/TOKEN` → recipient opens → SPA at `/share/TOKEN` → `ShareConfig.vue` calls `api.get('/share/TOKEN')` → WRONG URL → 404 → "Expired"
- **PresharedKey**: Always generated via `wgtypes.GenerateKey()` in all 3 creation paths. Always included in config text. No toggle to disable. Device-agent `WireGuardConfig` struct doesn't parse it.
- **WireGuard kernel**: `wgmanager_linux.go` already handles empty PresharedKey correctly (zero-value key = no PSK)

---

## Work Objectives

### Core Objective
1. Fix share link so recipients can actually retrieve the config
2. Allow admin to disable PresharedKey per-peer and set server-wide default

### Concrete Deliverables
- `apps/dashboard-ui/src/views/ShareConfig.vue` — use `fetch()` instead of Axios
- `apps/server-core/internal/models/models.go` — add `DisablePresharedKey` to Device, `DefaultDisablePresharedKey` to WgServer
- `apps/server-core/api/peers.go` — conditional PSK in CreatePeer + getDeviceConfig
- `apps/server-core/api/devices.go` — DisablePresharedKey in UpdateDeviceRequest + handler
- `apps/server-core/api/share.go` — conditional PSK in share config
- `apps/server-core/api/provisioning.go` — conditional PSK generation + payload
- `apps/dashboard-ui/src/components/AddPeerModal.vue` — PSK toggle
- `apps/dashboard-ui/src/views/DeviceDetail.vue` — PSK toggle
- `apps/dashboard-ui/src/views/Servers.vue` — default PSK setting
- `apps/device-agent/internal/client/provisioning.go` — add PresharedKey field
- `apps/device-agent/internal/tunnel/wireguard.go` — conditional preshared_key in UAPI

### Definition of Done
- [ ] Share link: curl `GET /share/{VALID_TOKEN}` → 200 with config_text
- [ ] Share link: curl `GET /share/{EXPIRED_TOKEN}` → 404 with error message
- [ ] Share link: Open share URL in browser → config displays, no "Expired" error
- [ ] PSK: Create device with PSK enabled → config_text includes `PresharedKey = <key>`
- [ ] PSK: Create device with PSK disabled → config_text has NO PresharedKey line
- [ ] PSK: Toggle PSK on existing device → updates correctly
- [ ] PSK: Server default disable PSK → new devices inherit
- [ ] PSK: Per-device toggle overrides server default
- [ ] Agent: Provision device with PSK → agent applies preshared_key via UAPI
- [ ] Agent: Provision device without PSK → agent does NOT emit preshared_key
- [ ] `npm run build` passes (dashboard-ui)
- [ ] `go build ./...` passes (server-core + device-agent)

### Must Have
- Share link URL mismatch fixed — recipient can download config
- Admin can disable PSK per-peer at creation/editing
- Server can set default "disable PSK" for all new peers on that server
- Device-agent receives and applies PresharedKey when present
- Device-agent handles missing/empty PresharedKey gracefully

### Must NOT Have (Guardrails)
- Do NOT refactor Axios `api` instance or `VITE_API_BASE_URL`
- Do NOT change server routing structure (`main.go` route definitions)
- Do NOT touch `shared/crypto/encryptor.go`
- Do NOT add PSK rotation, expiry, or custom/user-defined PSK values
- Do NOT add bulk tools for mass-updating existing devices
- Do NOT add new provisioning protocols or agent restart mechanisms
- Do NOT retroactively apply server default to existing devices
- Do NOT change how existing tunnels work mid-session

---

## Verification Strategy

> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed.

### Test Decision
- **Infrastructure exists**: Yes (Go tests + test DB + miniredis)
- **Automated tests**: Tests-after (add/update tests after implementation)
- **Framework**: Go `testing` + miniredis + httptest
- **Agent-Executed QA**: curl for API, fetch for share link, npm run build, go build

### QA Policy
Every task MUST include agent-executed QA scenarios.
- **API/Backend**: curl — specific HTTP methods, paths, request bodies, expected status codes + response fields
- **Frontend**: npm run build + grep for expected patterns
- **Agent**: grep for struct fields + build verification
- **Evidence**: Saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.txt`

---

## Execution Strategy

### Parallel Execution Waves

```
Wave 1 (Foundation — 2 parallel tasks):
├── Task 1: Model migration (Device + WgServer fields) [quick]
├── Task 2: Share link fix (ShareConfig.vue) [quick]

Wave 2 (Backend — 4 parallel tasks, blocked by Task 1):
├── Task 3: peers.go — conditional PSK in CreatePeer + getDeviceConfig [quick]
├── Task 4: share.go — conditional PSK in share config [quick]
├── Task 5: provisioning.go — conditional PSK gen + payload [quick]
├── Task 6: devices.go — DisablePresharedKey in update handler [quick]

Wave 3 (Frontend + Agent — 4 parallel tasks):
├── Task 7: AddPeerModal.vue — PSK disable toggle [visual-engineering]
├── Task 8: DeviceDetail.vue — PSK disable toggle [visual-engineering]
├── Task 9: Servers.vue — default PSK disable setting [visual-engineering]
├── Task 10: Device-agent — PresharedKey struct + UAPI [quick]

Wave FINAL (Parallel reviews):
├── Task F1: Plan compliance audit (oracle)
├── Task F2: Code quality + build tests (unspecified-high)
├── Task F3: Real manual QA (unspecified-high)
├── Task F4: Scope fidelity check (deep)
```

---

## TODOs

- [x] 1. Database model migration — add DisablePresharedKey fields

  **What to do**:
  - In `apps/server-core/internal/models/models.go`:
    1. Add to `Device` struct: `DisablePresharedKey bool \`json:"disable_preshared_key" gorm:"default:false"\``
    2. Add to `WgServer` struct: `DefaultDisablePresharedKey bool \`json:"default_disable_preshared_key" gorm:"default:false"\``
  - `main_dev.go` already runs GORM AutoMigrate with `-tags dev`. The `default:false` tag ensures existing rows get `false` (not NULL).
  - **Note**: GORM `default:false` on bool works — existing rows will backfill correctly.

  **Must NOT do**:
  - Do NOT run `ALTER TABLE` manually — AutoMigrate handles this
  - Do NOT touch `models_test.go` or `migrations.go`

  **Recommended Agent Profile**:
  - **Category**: `quick`
    - Reason: 2 lines added, zero logic change, pure schema

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 1 (with Task 2)
  - **Blocks**: Tasks 3-9, F1-F4
  - **Blocked By**: None

  **References**:
  - `apps/server-core/internal/models/models.go:57-84` — Device struct (add field near line 80)
  - `apps/server-core/internal/models/models.go:25-48` — WgServer struct (add field near line 45)

  **Acceptance Criteria**:
  - [ ] `go build ./...` passes
  - [ ] Grep confirms new fields exist in model

  **QA Scenarios**:
  ```
  Scenario: Device model has DisablePresharedKey
    Tool: Bash (grep)
    Steps:
      1. grep -n "DisablePresharedKey" apps/server-core/internal/models/models.go
    Expected Result: Match found with gorm tag "default:false"
    Evidence: .sisyphus/evidence/task-1-model-field.txt

  Scenario: WgServer model has DefaultDisablePresharedKey
    Tool: Bash (grep)
    Steps:
      1. grep -n "DefaultDisablePresharedKey" apps/server-core/internal/models/models.go
    Expected Result: Match found with gorm tag "default:false"
    Evidence: .sisyphus/evidence/task-1-server-default.txt

  Scenario: Build passes
    Tool: Bash
    Steps:
      1. cd apps/server-core && go build ./...
    Expected Result: Exit 0
    Evidence: .sisyphus/evidence/task-1-build.txt
  ```

  **Commit**: YES (with Tasks 3-6)
  - Message: `feat(model): add DisablePresharedKey flags to Device + WgServer`

---

- [x] 2. Fix share link URL mismatch in ShareConfig.vue

  **What to do**:
  - In `apps/dashboard-ui/src/views/ShareConfig.vue`:
    1. Remove `import { api } from '../services/api'` (line 36)
    2. Change `load()` function (lines 43-52) to use `fetch()` instead of `api.get()`:
    ```typescript
    const load = async () => {
      try {
        const res = await fetch(`/share/${route.params.token}`)
        if (!res.ok) {
          throw new Error(`HTTP ${res.status}`)
        }
        const data = await res.json()
        configText.value = data.config_text
      } catch (err) {
        error.value = true
      } finally {
        loading.value = false
      }
    }
    ```
  - **Why**: The `api` Axios instance has `baseURL = VITE_API_BASE_URL` which includes `/api/v1` prefix. Share endpoint is at root `GET /share/:token`. Using `fetch()` hits the origin domain correctly.

  **Must NOT do**:
  - Do NOT import or use `api` (avoid JWT auth header on public route)
  - Do NOT change router config or route definitions
  - Do NOT modify server `main.go` routes
  - Do NOT modify `api.ts` (Axios config)

  **Recommended Agent Profile**:
  - **Category**: `quick`
    - Reason: Single file, ~15 lines change, straightforward

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 1 (with Task 1)
  - **Blocks**: F1-F4
  - **Blocked By**: None

  **References**:
  - `apps/dashboard-ui/src/views/ShareConfig.vue:33-52` — entire script section. Line 45 is the api.get call to replace.
  - `apps/dashboard-ui/src/services/api.ts:4-5` — baseURL = VITE_API_BASE_URL (contains /api/v1). Confirms why api.get is wrong.
  - `apps/server-core/main.go:268` — `r.GET("/share/:token", shareHandler.GetShare)` — root level, not under /api/v1.

  **Acceptance Criteria**:
  - [ ] `npm run build` passes
  - [ ] Grep confirms no `api.get(` in ShareConfig.vue
  - [ ] Grep confirms `fetch(` is used for share endpoint

  **QA Scenarios**:
  ```
  Scenario: ShareConfig no longer uses api.get
    Tool: Bash (grep)
    Steps:
      1. grep -n "api\." apps/dashboard-ui/src/views/ShareConfig.vue
    Expected Result: No matches (api not imported)
    Evidence: .sisyphus/evidence/task-2-no-api.txt

  Scenario: ShareConfig uses fetch instead
    Tool: Bash (grep)
    Steps:
      1. grep -n "fetch(" apps/dashboard-ui/src/views/ShareConfig.vue
    Expected Result: Match found in load() function
    Evidence: .sisyphus/evidence/task-2-fetch.txt

  Scenario: Build passes
    Tool: Bash
    Steps:
      1. cd apps/dashboard-ui && npm run build
    Expected Result: Exit 0
    Evidence: .sisyphus/evidence/task-2-build.txt
  ```

  **Commit**: YES (separate)
  - Message: `fix(ui): use fetch() for share endpoint to fix URL mismatch /api/v1 prefix bug`

---

- [x] 3. Conditionally generate + output PresharedKey in peers.go

  **What to do**:
  Changes in `apps/server-core/api/peers.go`:

  **3a. `CreatePeer()` — conditional PSK generation** (lines 76-80):
  ```go
  // OLD:
  psk, err := wgtypes.GenerateKey()
  if err != nil {
      c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate preshared key"})
      return
  }
  
  // NEW: Generate PSK only if not disabled
  var pskStr string
  if !req.DisablePresharedKey {
      psk, err := wgtypes.GenerateKey()
      if err != nil {
          c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate preshared key"})
          return
      }
      pskStr = psk.String()
  }
  ```

  Also add `DisablePresharedKey` to `CreatePeerRequest` struct (line 30-34):
  ```go
  type CreatePeerRequest struct {
      Name                string `json:"name" binding:"required"`
      WgServerID          string `json:"wg_server_id" binding:"required"`
      AllowInternet       bool   `json:"allow_internet"`
      DisablePresharedKey bool   `json:"disable_preshared_key"`
  }
  ```

  And in the Device struct creation (line 94-107), change:
  ```go
  // OLD:
  PresharedKey:        psk.String(),
  
  // NEW:
  DisablePresharedKey: req.DisablePresharedKey,
  PresharedKey:        pskStr,
  ```

  **3b. `getDeviceConfig()` — conditional PSK in config text** (lines 226-246):
  Change the configText format string to conditionally include PresharedKey:
  ```go
  pskLine := fmt.Sprintf("PresharedKey = %s", device.PresharedKey)
  if device.DisablePresharedKey || device.PresharedKey == "" {
      pskLine = ""
  }
  ```
  Then change the format string:
  ```go
  configText := fmt.Sprintf(`[Interface]
  PrivateKey = %s
  Address = %s/%s
  DNS = %s%s
  
  [Peer]
  PublicKey = %s
  %s
  AllowedIPs = %s
  Endpoint = %s%s`,
      device.PrivateKey,
      *device.InternalIP,
      addrPrefix,
      dns,
      mtuLine,
      wgServer.PublicKey,
      pskLine,
      allowedIPs,
      wgServer.PublicEndpoint,
      keepaliveLine,
  )
  ```
  Note: If `pskLine` is empty, the blank line with `%s` will produce `\n\n` — need to handle carefully. Better approach: build config text with a conditional block.

  **Better approach**: Build config string with conditional parts:
  ```go
  var pskBlock string
  if device.PresharedKey != "" && !device.DisablePresharedKey {
      pskBlock = fmt.Sprintf("PresharedKey = %s\n", device.PresharedKey)
  }
  
  configText := fmt.Sprintf(`[Interface]
  PrivateKey = %s
  Address = %s/%s
  DNS = %s%s
  
  [Peer]
  PublicKey = %s
  %sAllowedIPs = %s
  Endpoint = %s%s`,
      device.PrivateKey,
      *device.InternalIP,
      addrPrefix,
      dns,
      mtuLine,
      wgServer.PublicKey,
      pskBlock,
      allowedIPs,
      wgServer.PublicEndpoint,
      keepaliveLine,
  )
  ```
  This way if pskBlock is empty, no blank line is inserted.

  **3c. `UpdateConfig()` — handle PresharedKey validation** (lines 311-319):
  Currently rejects PresharedKey changes. Keep this validation — users should use the toggle, not edit config manually.

  **Must NOT do**:
  - Do NOT touch `UpdateConfig()` validation logic
  - Do NOT remove the PresharedKey change rejection (users should use toggle)
  - Do NOT touch `wg_test.go`

  **Parallelization**:
  - **Can Run In Parallel**: YES (with Tasks 4, 5, 6)
  - **Parallel Group**: Wave 2
  - **Blocks**: Tasks 7-9, F1-F4
  - **Blocked By**: Task 1

  **References**:
  - `apps/server-core/api/peers.go:30-34` — CreatePeerRequest struct
  - `apps/server-core/api/peers.go:76-80` — PSK generation in CreatePeer
  - `apps/server-core/api/peers.go:226-246` — getDeviceConfig config text generation
  - `apps/server-core/api/peers.go:311-319` — UpdateConfig PresharedKey validation (keep as-is)

  **Acceptance Criteria**:
  - [ ] go build ./... passes
  - [ ] CreatePeer with disable_preshared_key=false → PSK in response
  - [ ] CreatePeer with disable_preshared_key=true → no PSK in response
  - [ ] getDeviceConfig omits PresharedKey line when device has DisablePresharedKey=true

  **QA Scenarios**:
  ```
  Scenario: CreatePeerRequest has DisablePresharedKey field
    Tool: Bash (grep)
    Steps:
      1. grep -n "DisablePresharedKey" apps/server-core/api/peers.go
    Expected Result: 2+ matches (struct + usage)
    Evidence: .sisyphus/evidence/task-3-request-struct.txt

  Scenario: CreatePeer conditionally generates PSK
    Tool: Bash (grep)
    Steps:
      1. grep -A5 "if !req.DisablePresharedKey" apps/server-core/api/peers.go
    Expected Result: Shows GenerateKey() inside the conditional
    Evidence: .sisyphus/evidence/task-3-conditional-gen.txt

  Scenario: getDeviceConfig conditionally includes PSK
    Tool: Bash (grep)
    Steps:
      1. grep -B2 -A3 "pskBlock\|PresharedKey =" apps/server-core/api/peers.go | head -20
    Expected Result: Shows pskBlock built conditionally, used in format string
    Evidence: .sisyphus/evidence/task-3-config-output.txt

  Scenario: Build passes
    Tool: Bash
    Steps:
      1. cd apps/server-core && go build ./...
    Expected Result: Exit 0
    Evidence: .sisyphus/evidence/task-3-build.txt
  ```

  **Commit**: YES (with tasks 1, 4, 5, 6)
  - Message: `feat(api): conditional PresharedKey generation and config output`

---

- [x] 4. Conditionally include PresharedKey in share config (share.go)

  **What to do**:
  - In `apps/server-core/api/share.go`, the `CreateShare()` function generates config text at lines 71-88.
  - Replace the hardcoded `PresharedKey = %s` with conditional inclusion:
  ```go
  var pskBlock string
  if device.PresharedKey != "" && !device.DisablePresharedKey {
      pskBlock = fmt.Sprintf("PresharedKey = %s\n", device.PresharedKey)
  }
  
  configText := fmt.Sprintf(`[Interface]
  PrivateKey = %s
  Address = %s/%s
  DNS = 1.1.1.1
  
  [Peer]
  PublicKey = %s
  %sAllowedIPs = %s
  Endpoint = %s`,
      device.PrivateKey,
      *device.InternalIP,
      addrPrefix,
      wgServer.PublicKey,
      pskBlock,
      allowedIPs,
      wgServer.PublicEndpoint,
  )
  ```

  **Must NOT do**:
  - Do NOT change the existing config structure (Interface, Peer sections)
  - Do NOT touch share_test.go

  **Parallelization**:
  - **Can Run In Parallel**: YES (with Tasks 3, 5, 6)
  - **Parallel Group**: Wave 2
  - **Blocks**: F1-F4
  - **Blocked By**: Task 1

  **References**:
  - `apps/server-core/api/share.go:71-88` — current config generation (lines 78 = PresharedKey)
  - `apps/server-core/api/peers.go:226-246` — same pattern to follow for conditional PSK block

  **Acceptance Criteria**:
  - [ ] go build ./... passes
  - [ ] Share config omits PresharedKey when device has DisablePresharedKey=true
  - [ ] Share config includes PresharedKey when device has DisablePresharedKey=false

  **QA Scenarios**:
  ```
  Scenario: Share config has conditional PSK
    Tool: Bash (grep)
    Steps:
      1. grep -n "pskBlock\|DisablePresharedKey" apps/server-core/api/share.go
    Expected Result: Matches showing conditional PSK logic
    Evidence: .sisyphus/evidence/task-4-share-psk.txt

  Scenario: Build passes
    Tool: Bash
    Steps:
      1. cd apps/server-core && go build ./...
    Expected Result: Exit 0
    Evidence: .sisyphus/evidence/task-4-build.txt
  ```

  **Commit**: YES (with tasks 1, 3, 5, 6)
  - Message: `feat(api): conditional PresharedKey in share config`

---

- [x] 5. Conditionally generate + output PresharedKey in provisioning.go

  **What to do**:
  Changes in `apps/server-core/api/provisioning.go`:

  **5a. Conditional PSK generation** (lines 105-110):
  ```go
  // OLD:
  psk, err := wgtypes.GenerateKey()
  if err != nil {
      tx.Rollback()
      c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate preshared key"})
      return
  }
  ...
  device.PresharedKey = psk.String()

  // NEW:
  var pskStr string
  if !device.DisablePresharedKey {
      psk, err := wgtypes.GenerateKey()
      if err != nil {
          tx.Rollback()
          c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate preshared key"})
          return
      }
      pskStr = psk.String()
  }
  ...
  device.PresharedKey = pskStr
  ```

  **5b. Conditional PSK in ConfigPayload** (lines 138-145):
  ```go
  payload := ConfigPayload{
      PrivateKey:   priv.String(),
      InternalIP:   *device.InternalIP,
      ServerPub:    wgServer.PublicKey,
      Endpoint:     wgServer.PublicEndpoint,
      DNS:          "1.1.1.1",
  }
  if pskStr != "" {
      payload.PresharedKey = pskStr
  }
  ```

  **Note**: `ConfigPayload.PresharedKey` is a string — if not set, it serializes as `"preshared_key":""`. The device-agent already ignores unknown fields silently. This is fine.

  **Must NOT do**:
  - Do NOT touch the provisioning test file (tests need updating but out of scope for this task)
  - Do NOT touch `ConfigPayload` struct (leave as-is, empty string = no PSK)

  **Parallelization**:
  - **Can Run In Parallel**: YES (with Tasks 3, 4, 6)
  - **Parallel Group**: Wave 2
  - **Blocks**: F1-F4
  - **Blocked By**: Task 1

  **References**:
  - `apps/server-core/api/provisioning.go:37-44` — ConfigPayload struct (PresharedKey field already exists)
  - `apps/server-core/api/provisioning.go:105-114` — PSK generation block
  - `apps/server-core/api/provisioning.go:138-145` — ConfigPayload construction

  **Acceptance Criteria**:
  - [ ] go build ./... passes
  - [ ] Provisioning with DisablePresharedKey=true → no PSK in ConfigPayload
  - [ ] Provisioning with DisablePresharedKey=false → PSK in ConfigPayload

  **QA Scenarios**:
  ```
  Scenario: Provisioning conditionally generates PSK
    Tool: Bash (grep)
    Steps:
      1. grep -B1 -A5 "if !device.DisablePresharedKey" apps/server-core/api/provisioning.go
    Expected Result: Shows conditional block with GenerateKey()
    Evidence: .sisyphus/evidence/task-5-conditional-gen.txt

  Scenario: ConfigPayload conditionally includes PSK
    Tool: Bash (grep)
    Steps:
      1. grep -A5 "if pskStr" apps/server-core/api/provisioning.go
    Expected Result: Shows the conditional payload.PresharedKey assignment
    Evidence: .sisyphus/evidence/task-5-payload.txt

  Scenario: Build passes
    Tool: Bash
    Steps:
      1. cd apps/server-core && go build ./...
    Expected Result: Exit 0
    Evidence: .sisyphus/evidence/task-5-build.txt
  ```

  **Commit**: YES (with tasks 1, 3, 4, 6)
  - Message: `feat(api): conditional PresharedKey in provisioning flow`

---

- [x] 6. Handle DisablePresharedKey in device Update handler (devices.go)

  **What to do**:
  Changes in `apps/server-core/api/devices.go`:

  **6a. Add to UpdateDeviceRequest** (lines 166-174):
  ```go
  type UpdateDeviceRequest struct {
      Name                string  `json:"name"`
      AllowInternet       *bool   `json:"allow_internet"`
      EndpointAllowedIPs  *string `json:"endpoint_allowed_ips"`
      DNS                 *string `json:"dns"`
      MTU                 *int    `json:"mtu"`
      PersistentKeepalive *int    `json:"persistent_keepalive"`
      Notes               *string `json:"notes"`
      DisablePresharedKey *bool   `json:"disable_preshared_key"`
  }
  ```

  **6b. Handle in Update handler** (between lines 192-213):
  ```go
  if req.DisablePresharedKey != nil {
      updates["disable_preshared_key"] = *req.DisablePresharedKey
      // If re-enabling PSK and no PSK exists, generate one
      if !*req.DisablePresharedKey && device.PresharedKey == "" {
          psk, err := wgtypes.GenerateKey()
          if err == nil {
              updates["preshared_key"] = psk.String()
          }
      }
      // If disabling PSK, clear the stored key
      if *req.DisablePresharedKey {
          updates["preshared_key"] = ""
      }
  }
  ```

  **Must NOT do**:
  - Do NOT touch `Create()` — that's the peer creation path (handled by peers.go)
  - Do NOT touch `List()`, `Get()`, `Delete()` handlers
  - Do NOT generate PSK outside the re-enable scenario

  **Parallelization**:
  - **Can Run In Parallel**: YES (with Tasks 3, 4, 5)
  - **Parallel Group**: Wave 2
  - **Blocks**: Tasks 7-9, F1-F4
  - **Blocked By**: Task 1

  **References**:
  - `apps/server-core/api/devices.go:166-174` — UpdateDeviceRequest struct
  - `apps/server-core/api/devices.go:176-235` — Update handler (lines 192-213 are the updates section)

  **Acceptance Criteria**:
  - [ ] go build ./... passes
  - [ ] PUT /devices/:id with disable_preshared_key=true → device.PresharedKey cleared
  - [ ] PUT /devices/:id with disable_preshared_key=false on device with PSK → no change to PSK
  - [ ] PUT /devices/:id with disable_preshared_key=false on device without PSK → new PSK generated

  **QA Scenarios**:
  ```
  Scenario: UpdateDeviceRequest has DisablePresharedKey
    Tool: Bash (grep)
    Steps:
      1. grep -n "DisablePresharedKey" apps/server-core/api/devices.go
    Expected Result: 2+ matches (struct + handler)
    Evidence: .sisyphus/evidence/task-6-request-field.txt

  Scenario: Update handler handles PSK toggle
    Tool: Bash (grep)
    Steps:
      1. grep -B1 -A6 "if req.DisablePresharedKey" apps/server-core/api/devices.go
    Expected Result: Shows the conditional block with PSK generation/clearing
    Evidence: .sisyphus/evidence/task-6-toggle-logic.txt

  Scenario: Build passes
    Tool: Bash
    Steps:
      1. cd apps/server-core && go build ./...
    Expected Result: Exit 0
    Evidence: .sisyphus/evidence/task-6-build.txt
  ```

  **Commit**: YES (with tasks 1, 3, 4, 5)
  - Message: `feat(api): handle DisablePresharedKey in device update handler`

---

- [x] 7. Add PSK disable toggle to AddPeerModal.vue

  **What to do**:
  In `apps/dashboard-ui/src/components/AddPeerModal.vue`:

  **7a. Add reactive state**:
  ```typescript
  const disablePresharedKey = ref(false)
  ```

  **7b. Add checkbox to template** (after the AllowInternet field):
  ```vue
  <div class="flex items-center space-x-3 mt-3">
    <input type="checkbox" id="disable-psk" v-model="disablePresharedKey"
           class="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-600 focus:ring-cyan-500" />
    <label for="disable-psk" class="text-sm text-gray-300">
      Disable PresharedKey
      <span class="text-xs text-gray-500 ml-1">(reduces connection security)</span>
    </label>
  </div>
  ```

  **7c. Include in API request** (in the submit handler):
  Pass `disable_preshared_key: disablePresharedKey.value` in the createPeer payload.

  **Must NOT do**:
  - Do NOT remove the existing AllowInternet toggle
  - Do NOT add custom PSK input (only yes/no toggle)
  - Do NOT change the existing API response handling

  **Parallelization**:
  - **Can Run In Parallel**: YES (with Tasks 8, 9, 10)
  - **Parallel Group**: Wave 3
  - **Blocks**: F1-F4
  - **Blocked By**: Tasks 1, 3

  **References**:
  - `apps/dashboard-ui/src/components/AddPeerModal.vue` — full component (read first, add field + state + API passthrough)
  - `apps/dashboard-ui/src/views/DeviceDetail.vue` — similar toggle pattern for reference

  **Acceptance Criteria**:
  - [ ] npm run build passes
  - [ ] Toggle exists in template
  - [ ] Toggle is passed in API request

  **QA Scenarios**:
  ```
  Scenario: Toggle exists in template
    Tool: Bash (grep)
    Steps:
      1. grep -n "disable-psk\|DisablePresharedKey\|disable_preshared_key" apps/dashboard-ui/src/components/AddPeerModal.vue
    Expected Result: 2+ matches (template + script)
    Evidence: .sisyphus/evidence/task-7-toggle-exists.txt

  Scenario: Build passes
    Tool: Bash
    Steps:
      1. cd apps/dashboard-ui && npm run build
    Expected Result: Exit 0
    Evidence: .sisyphus/evidence/task-7-build.txt
  ```

  **Commit**: YES (with tasks 8, 9)
  - Message: `feat(ui): add PresharedKey disable toggle to peer creation form`

---

- [x] 8. Add PSK disable toggle to DeviceDetail.vue

  **What to do**:
  In `apps/dashboard-ui/src/views/DeviceDetail.vue`:

  **8a. Add reactive state**:
  ```typescript
  const disablePresharedKey = ref(false)
  ```

  **8b. Load current state** from device data in `onMounted` or `watch`:
  ```typescript
  disablePresharedKey.value = device.disable_preshared_key || false
  ```

  **8c. Add toggle to the device edit form**:
  ```vue
  <div class="flex items-center space-x-3 mt-3">
    <input type="checkbox" id="device-disable-psk" v-model="disablePresharedKey"
           class="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-600 focus:ring-cyan-500" />
    <label for="device-disable-psk" class="text-sm text-gray-300">
      Disable PresharedKey
      <span class="text-xs text-gray-500 ml-1">(reduces connection security)</span>
    </label>
  </div>
  ```

  **8d. Include in update payload**:
  Pass `disable_preshared_key: disablePresharedKey.value` in the device update request.

  **Must NOT do**:
  - Do NOT change the existing edit form layout or other fields
  - Do NOT add custom PSK input

  **Parallelization**:
  - **Can Run In Parallel**: YES (with Tasks 7, 9, 10)
  - **Parallel Group**: Wave 3
  - **Blocks**: F1-F4
  - **Blocked By**: Tasks 1, 6

  **References**:
  - `apps/dashboard-ui/src/views/DeviceDetail.vue` — read expected edit form section
  - `apps/server-core/api/devices.go:166-174` — UpdateDeviceRequest struct (for field name)

  **Acceptance Criteria**:
  - [ ] npm run build passes
  - [ ] Toggle in device edit form, bound to device.disable_preshared_key
  - [ ] Toggle sent in update API request

  **QA Scenarios**:
  ```
  Scenario: Toggle exists in DeviceDetail
    Tool: Bash (grep)
    Steps:
      1. grep -n "disable_preshared_key\|DisablePresharedKey" apps/dashboard-ui/src/views/DeviceDetail.vue
    Expected Result: 2+ matches (template + script)
    Evidence: .sisyphus/evidence/task-8-toggle-exists.txt

  Scenario: Build passes
    Tool: Bash
    Steps:
      1. cd apps/dashboard-ui && npm run build
    Expected Result: Exit 0
    Evidence: .sisyphus/evidence/task-8-build.txt
  ```

  **Commit**: YES (with tasks 7, 9)
  - Message: `feat(ui): add PresharedKey disable toggle to device detail edit form`

---

- [x] 9. Add default PSK disable setting to Servers.vue

  **What to do**:
  In `apps/dashboard-ui/src/views/Servers.vue`:

  **9a. Add reactive state** in the server edit form:
  ```typescript
  const defaultDisablePresharedKey = ref(false)
  ```

  **9b. Load from server data**:
  ```typescript
  defaultDisablePresharedKey.value = editingServer.default_disable_preshared_key || false
  ```

  **9c. Add toggle to server edit form** (near peer defaults section):
  ```vue
  <div class="flex items-center space-x-3 mt-3">
    <input type="checkbox" id="server-default-disable-psk" v-model="defaultDisablePresharedKey"
           class="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-600 focus:ring-cyan-500" />
    <label for="server-default-disable-psk" class="text-sm text-gray-300">
      Disable PresharedKey for new peers by default
      <span class="text-xs text-gray-500 ml-1">(can be overridden per-peer)</span>
    </label>
  </div>
  ```

  **9d. Include in update payload**:
  Pass `default_disable_preshared_key: defaultDisablePresharedKey.value` in the server update request.

  **Must NOT do**:
  - Do NOT change existing peer default fields (DNS, MTU, Keepalive, AllowedIPs)
  - Do NOT add any other default settings

  **Parallelization**:
  - **Can Run In Parallel**: YES (with Tasks 7, 8, 10)
  - **Parallel Group**: Wave 3
  - **Blocks**: F1-F4
  - **Blocked By**: Tasks 1, 3

  **References**:
  - `apps/dashboard-ui/src/views/Servers.vue` — read the server edit form (peer defaults section)
  - `apps/server-core/internal/models/models.go:45` — WgServer.DefaultDisablePresharedKey field

  **Acceptance Criteria**:
  - [ ] npm run build passes
  - [ ] Toggle exists in server edit form
  - [ ] Toggle sent in server update request

  **QA Scenarios**:
  ```
  Scenario: Default toggle exists in Servers.vue
    Tool: Bash (grep)
    Steps:
      1. grep -n "default_disable_preshared_key\|DefaultDisablePresharedKey" apps/dashboard-ui/src/views/Servers.vue
    Expected Result: 2+ matches (template + script)
    Evidence: .sisyphus/evidence/task-9-toggle-exists.txt

  Scenario: Build passes
    Tool: Bash
    Steps:
      1. cd apps/dashboard-ui && npm run build
    Result: Exit 0
    Evidence: .sisyphus/evidence/task-9-build.txt
  ```

  **Commit**: YES (with tasks 7, 8)
  - Message: `feat(ui): add default PresharedKey disable setting to server edit form`

---

- [x] 10. Fix device-agent PresharedKey gap (provisioning.go + wireguard.go)

  **What to do**:
  Changes in `apps/device-agent/`:

  **10a. Add PresharedKey to WireGuardConfig struct** in `apps/device-agent/internal/client/provisioning.go`:
  ```go
  type WireGuardConfig struct {
      PrivateKey  string `json:"private_key"`
      PresharedKey string `json:"preshared_key"`
      InternalIP  string `json:"internal_ip"`
      ServerPub   string `json:"server_pub"`
      Endpoint    string `json:"endpoint"`
      DNS         string `json:"dns"`
  }
  ```

  **10b. Update ConvertToUAPI()** in `apps/device-agent/internal/tunnel/wireguard.go`:
  Change from:
  ```go
  return fmt.Sprintf(`private_key=%s
  public_key=%s
  endpoint=%s
  allowed_ip=%s`, ...)
  ```
  To:
  ```go
  pskLine := ""
  if presharedKey != "" {
      pskLine = fmt.Sprintf("preshared_key=%s\n", presharedKey)
  }
  return fmt.Sprintf(`private_key=%s
  public_key=%s
  %sendpoint=%s
  allowed_ip=%s`, privateKey, publicKey, pskLine, endpoint, allowedIP)
  ```

  **10c. Update function signature** and callers:
  - Change `ConvertToUAPI` signature to accept `presharedKey string`
  - In `main.go` where it's called, pass `cfg.PresharedKey`

  **Must NOT do**:
  - Do NOT touch `shared/crypto/encryptor.go` (known debt, keep duplicated)
  - Do NOT change the provisioning function flow (decrypt, parse, apply — stay the same)

  **Parallelization**:
  - **Can Run In Parallel**: YES (with Tasks 7, 8, 9)
  - **Parallel Group**: Wave 3
  - **Blocks**: F1-F4
  - **Blocked By**: Task 1 (conceptual dependency — agent reads Device model changes but doesn't block on compile)

  **References**:
  - `apps/device-agent/internal/client/provisioning.go:28-34` — WireGuardConfig struct (add PresharedKey)
  - `apps/device-agent/internal/tunnel/wireguard.go:83-97` — ConvertToUAPI function
  - `apps/device-agent/main.go:38` — caller of ConvertToUAPI
  - `apps/server-core/api/provisioning.go:138-145` — ConfigPayload struct (already has PresharedKey, confirmed)

  **Acceptance Criteria**:
  - [ ] go build ./... passes (device-agent)
  - [ ] WireGuardConfig has PresharedKey field
  - [ ] ConvertToUAPI conditionally emits preshared_key when non-empty
  - [ ] ConvertToUAPI omits preshared_key when empty

  **QA Scenarios**:
  ```
  Scenario: WireGuardConfig has PresharedKey field
    Tool: Bash (grep)
    Steps:
      1. grep -n "PresharedKey\|preshared_key" apps/device-agent/internal/client/provisioning.go
    Expected Result: Shows PresharedKey string field with json tag
    Evidence: .sisyphus/evidence/task-10-struct-field.txt

  Scenario: ConvertToUAPI accepts presharedKey param
    Tool: Bash (grep)
    Steps:
      1. grep -n "func ConvertToUAPI" apps/device-agent/internal/tunnel/wireguard.go
    Expected Result: Shows function signature with presharedKey string param
    Evidence: .sisyphus/evidence/task-10-uapi-sig.txt

  Scenario: ConvertToUAPI conditionally emits preshared_key
    Tool: Bash (grep)
    Steps:
      1. grep -B1 -A3 "if presharedKey" apps/device-agent/internal/tunnel/wireguard.go
    Expected Result: Shows conditional pskLine building
    Evidence: .sisyphus/evidence/task-10-uapi-conditional.txt

  Scenario: Build passes
    Tool: Bash
    Steps:
      1. cd apps/device-agent && go build ./...
    Expected Result: Exit 0
    Evidence: .sisyphus/evidence/task-10-build.txt
  ```

  **Commit**: YES (separate, agent is independent submodule)
  - Message: `feat(agent): add PresharedKey support to WireGuard config and UAPI`

---

## Final Verification Wave

- [x] F1. **Plan Compliance Audit** — `oracle`
  Read the plan end-to-end. For each Must Have: verify implementation exists. For each Must NOT Have: search codebase for forbidden patterns (refactored Axios, crypto/encryptor.go changes, etc.). Check evidence files exist in `.sisyphus/evidence/`. Compare deliverables against plan.
  Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT`

- [x] F2. **Code Quality + Build Tests** — `unspecified-high`
  Run `go build ./...` on ALL 3 submodules (server-core, dashboard-ui, device-agent). Run `npm run build` on dashboard-ui. Check for: unused imports, commented-out code, AI slop (excessive comments, over-abstraction).
  Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Files [N clean/N issues] | VERDICT`

- [x] F3. **Real Manual QA** — `unspecified-high`
  Execute these end-to-end scenarios:
  1. **Share link fix**: Call POST /api/v1/devices/:id/share → get token → curl GET /share/TOKEN → verify 200 with config_text
  2. **PSK creation**: POST /api/v1/peers with disable_preshared_key=false → response has PresharedKey. POST with true → no PresharedKey
  3. **PSK config**: GET /devices/:id/config for both PSK states → verify correct format
  4. **PSK toggle**: PUT /devices/:id with disable_preshared_key=true → GET device → verify field changed
  5. **Server default**: Update server setting → create peer on that server → verify inheritance
  6. **Share config PSK**: Create share for device with PSK disabled → verify share config omits PSK
  7. **Agent build**: go build ./... in device-agent → verify WireGuardConfig has PresharedKey field
  Save evidence to `.sisyphus/evidence/final-qa/`.
  Output: `Scenarios [N/N pass] | Integration [N/N] | VERDICT`

- [x] F4. **Scope Fidelity Check** — `deep`
  Read the actual diffs for all changed files. Verify 1:1 with spec. No missing, no scope creep. Check Must NOT do compliance. Detect cross-task contamination.
  Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | VERDICT`

---

## Commit Strategy

- **Task 2** (standalone): `fix(ui): use fetch() for share endpoint to fix /api/v1 prefix bug`
- **Task 1 + 3-6**: `feat: conditional PresharedKey across model, creation, config, share, provisioning, device update`
- **Tasks 7-9**: `feat(ui): add PresharedKey disable toggles to AddPeerModal, DeviceDetail, Servers`
- **Task 10** (standalone agent): `feat(agent): add PresharedKey support to WireGuard config and UAPI`
- **Parent repo**: `feat: submodule refs for share link fix + PresharedKey disable feature`

---

## Success Criteria

### Verification Commands
```bash
cd apps/server-core && go build ./...
cd apps/dashboard-ui && npm run build
cd apps/device-agent && go build ./...
curl -v http://localhost:8080/share/{VALID_TOKEN}
curl -v http://localhost:8080/api/v1/peers -X POST -d '{"name":"test","wg_server_id":"...","disable_preshared_key":true}'
```

### Final Checklist
- [ ] All "Must Have" present
- [ ] All "Must NOT Have" absent
- [ ] All builds pass
- [ ] Share link: valid token returns 200 with config
- [ ] Share link: expired token returns 404 with error
- [ ] PSK disabled: no PresharedKey in any generated config
- [ ] PSK enabled (default): PresharedKey present in all generated configs
- [ ] Agent: builds with PresharedKey field in WireGuardConfig
- [ ] Agent: ConvertToUAPI conditionally emits preshared_key



