Files
bookhoard/IMPLEMENTATION_EXACT.md
T
john-okeefe fdfbec01ef docs: Fix device authentication implementation plan
- Update section 1.6.3 to pass baseURL as template parameter instead of hardcoding
- Add handler update note for passing cfg.BaseURL to template
- Fix TypeScript event handling in section 1.6.4:
  - Add event parameter to regenerateDeviceToken function signature
  - Update all onclick handlers to explicitly pass event object
  - Fixes deprecated implicit event in modern browsers
- Remove section 1.9 (Device Identifier Verification) as it was never implemented
- Clarify device authentication strategy: Kobo uses URL path tokens, KOReader uses Bearer headers
2026-02-13 10:13:26 -05:00

66 KiB
Raw Blame History

Device Authentication & Sync Implementation - Exact Code Changes

Document Purpose: This document contains every single line of code that needs to be added, modified, or removed to implement the device authentication and sync system. Each change is explained with context and follows PROJECT_GUIDELINES.md.

Implementation Status: Ready to implement Last Updated: Based on IMPLEMENTATION_PLAN.md (2026-02-12) Guidelines Compliance: Follows all PROJECT_GUIDELINES.md requirements


Table of Contents

  1. Phase 1: Enhanced Authentication (Week 1)
    • Database Query Addition
    • Middleware Enhancement
    • Router Updates
    • Backend Handler Addition
    • Frontend Template Updates
  2. Phase 2: Kobo Integration (Week 1-2)
  3. Phase 3: OPDS Security (Week 2-3)
    • Router Enhancement
    • Bruno API Tests
  4. Phase 4: Testing & Documentation (Week 4)
    • End-to-End Tests
    • Security Documentation

Phase 1: Enhanced Authentication

Overview

Goal: Support both API key in URL path (Kobo) and Bearer token in header (KOReader)

Files to Modify:

  • internal/database/queries/queries.sql - Add token regeneration query
  • internal/middleware/device_auth.go - Support URL path token
  • internal/router/sync.go - Update Kobo routes
  • internal/router/device.go - Add regenerate token route
  • internal/handlers/devices.go - Add regenerate token handler
  • templates/devices.templ - Add copy/regenerate UI
  • web/src/device-management.ts - Add device management TypeScript
  • bruno/sync-kobo/api.bru - Add URL path token requests
  • bruno/devices/regenerate-*.bru - New token regeneration test files
  • bruno/opds/*-*.bru - New OPDS authentication test files

1.1 Database Query Addition

File: internal/database/queries/queries.sql

Location: After line 770 (after UpdateDeviceLastSeen query)

Explanation: Add a new query to update only the auth_token field for token regeneration. This is separate from UpdateDevice which doesn't modify auth_token.

ADD THIS CODE:

-- name: UpdateDeviceAuthToken :one
UPDATE devices
SET
    auth_token = $2,
    updated_at = NOW()
WHERE id = $1
RETURNING *;

Complete Context (lines 764-776 after change):

-- name: UpdateDeviceLastSeen :one
UPDATE devices
SET
    last_seen = NOW(),
    updated_at = NOW()
WHERE id = $1
RETURNING *;

-- name: UpdateDeviceAuthToken :one
UPDATE devices
SET
    auth_token = $2,
    updated_at = NOW()
WHERE id = $1
RETURNING *;

Verification: Run go build ./... after adding this query to ensure sqlc generates the new function correctly.


1.2 Middleware Enhancement

File: internal/middleware/device_auth.go

Location: Lines 37-108 (function Authenticate)

Current Implementation: Only checks Authorization: Bearer {token} header

Required Change: Add fallback to check URL path parameter and query parameter

REPLACE LINES 37-62 with:

func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		var device database.Devices
		var err error

		// Method 1: Try Bearer token header (KOReader, API clients, OPDS)
		authHeader := c.Request().Header.Get("Authorization")
		if authHeader != "" {
			if !strings.HasPrefix(authHeader, "Bearer ") {
				return c.JSON(http.StatusUnauthorized, map[string]string{
					"error": "invalid authorization header format",
				})
			}

			token := strings.TrimPrefix(authHeader, "Bearer ")
			device, err = m.db.GetDeviceByAuthToken(c.Request().Context(), token)
			if err == nil {
				// Found device via Bearer token, continue to validation
				goto validateDevice
			}
		}

		// Method 2: Try URL path parameter (Kobo sync, OPDS)
		// Route format: /api/sync/kobo/:token/...
		urlToken := c.Param("token")
		if urlToken != "" {
			device, err = m.db.GetDeviceByAuthToken(c.Request().Context(), urlToken)
			if err == nil {
				// Found device via URL path token, continue to validation
				goto validateDevice
			}
		}

		// Method 3: Try query parameter (OPDS catalog access)
		// URL format: /opds/devices/:deviceId/catalog?token=...
		queryToken := c.QueryParam("token")
		if queryToken != "" {
			device, err = m.db.GetDeviceByAuthToken(c.Request().Context(), queryToken)
			if err == nil {
				// Found device via query parameter token, continue to validation
				goto validateDevice
			}
		}

		// All authentication methods failed
		return c.JSON(http.StatusUnauthorized, map[string]string{
			"error": "authentication required - use Bearer token or API key",
		})

	validateDevice:

Explanation:

  • goto validateDevice jumps to common validation code (label at line 221 in the complete function below)
  • This is Go's idiomatic use of goto for error handling (allowed by PROJECT_GUIDELINES.md)
  • Tries Bearer header first (for KOReader, API clients), then URL path (Kobo), then query param (OPDS)
  • Each device type uses only one method: Kobo→URL path, KOReader→Bearer header
  • The validateDevice: label is preserved in the unchanged section of the function (lines 221-270)

KEEP THE REST OF THE FUNCTION THE SAME (lines 62-270 remain unchanged, including the validateDevice: label at line 221)

Complete Function After Changes (reference for verification, shows how goto validateDevice connects to the label):

func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		var device database.Devices
		var err error

		// Method 1: Try Bearer token header (KOReader, API clients, OPDS)
		authHeader := c.Request().Header.Get("Authorization")
		if authHeader != "" {
			if !strings.HasPrefix(authHeader, "Bearer ") {
				return c.JSON(http.StatusUnauthorized, map[string]string{
					"error": "invalid authorization header format",
				})
			}

			token := strings.TrimPrefix(authHeader, "Bearer ")
			device, err = m.db.GetDeviceByAuthToken(c.Request().Context(), token)
			if err == nil {
				// Found device via Bearer token, continue to validation
				goto validateDevice
			}
		}

		// Method 2: Try URL path parameter (Kobo sync, OPDS)
		// Route format: /api/sync/kobo/:token/...
		urlToken := c.Param("token")
		if urlToken != "" {
			device, err = m.db.GetDeviceByAuthToken(c.Request().Context(), urlToken)
			if err == nil {
				// Found device via URL path token, continue to validation
				goto validateDevice
			}
		}

		// Method 3: Try query parameter (OPDS catalog access)
		// URL format: /opds/devices/:deviceId/catalog?token=...
		queryToken := c.QueryParam("token")
		if queryToken != "" {
			device, err = m.db.GetDeviceByAuthToken(c.Request().Context(), queryToken)
			if err == nil {
				// Found device via query parameter token, continue to validation
				goto validateDevice
			}
		}

		// All authentication methods failed
		return c.JSON(http.StatusUnauthorized, map[string]string{
			"error": "authentication required - use Bearer token or API key",
		})

	validateDevice:
		if !device.SyncEnabled.Bool || !device.SyncEnabled.Valid {
			return c.JSON(http.StatusForbidden, map[string]string{
				"error": "device sync is disabled",
			})
		}

		requestType := m.getRequestType(c.Request().URL.Path)
		deviceUUID := uuid.UUID(device.ID.Bytes)
		deviceID := deviceUUID.String()

		config := DeviceRateLimitConfig{
			SyncRequestsPerMinute:     60,
			ProgressUpdatesPerMinute: 120,
			MetadataRequestsPerMinute: 30,
		}

		if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
			remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
			c.Response().Header().Set("X-RateLimit-Limit", "60")
			c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
			c.Response().Header().Set("X-RateLimit-Reset", "60")
			return c.JSON(http.StatusTooManyRequests, map[string]string{
				"error":     "rate limit exceeded",
				"message":   "Too many requests",
				"remaining": strconv.Itoa(remaining),
			})
		}

		remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
		c.Response().Header().Set("X-RateLimit-Limit", "60")
		c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))

		ctx := DeviceContext{
			ID:               device.ID.Bytes,
			UserID:           device.UserID.Bytes,
			DeviceName:       device.DeviceName,
			DeviceType:       device.DeviceType,
			DeviceIdentifier: device.DeviceIdentifier,
			SyncEnabled:      device.SyncEnabled.Bool && device.SyncEnabled.Valid,
			AutoSync:         device.AutoSync.Bool && device.AutoSync.Valid,
		}

		c.Set("device", device)
		c.Set("device_ctx", ctx)
		c.Set("device_id", device.ID.Bytes)

		return next(c)
	}
}

Verification Steps:

  1. Run go build ./internal/middleware
  2. Run go test ./internal/middleware/... -v
  3. Review git diff internal/middleware/device_auth.go

1.3 Router Updates - Kobo Routes

File: internal/router/sync.go

Location: Lines 31-38

Current Implementation:

// Kobo sync routes (device authentication required)
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
koboSync := e.Group("/api/sync/kobo")
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
koboSync.POST("/v1/analytics/gettests", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.AnalyticsGettests))
koboSync.GET("/v1/initialization", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Initialization))
koboSync.POST("/sync-from-server", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.SyncFromServer))

Required Change: Change route group from /api/sync/kobo to /api/sync/kobo/:token

REPLACE LINE 33:

// BEFORE:
koboSync := e.Group("/api/sync/kobo")

// AFTER:
koboSync := e.Group("/api/sync/kobo/:token")

Complete Context After Change (lines 31-39):

// Kobo sync routes (device authentication required)
// Kobo devices use URL path: /api/sync/kobo/{token}/markup
// API clients can use Authorization header: Authorization: Bearer {token}
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
koboSync := e.Group("/api/sync/kobo/:token")
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
koboSync.POST("/v1/analytics/gettests", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.AnalyticsGettests))
koboSync.GET("/v1/initialization", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Initialization))
koboSync.POST("/sync-from-server", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.SyncFromServer))

Explanation:

  • URL path parameter :token allows Kobo devices to send: POST /api/sync/kobo/dev_abc123.../markup
  • Middleware now extracts token from path (see middleware changes above)
  • Routes also accept Bearer tokens for API clients/automation, but Kobo devices will never send Bearer tokens
  • KOReader uses Bearer token exclusively, Kobo uses URL path token exclusively

Verification: Run go build ./internal/router


1.4 Router Updates - Device Token Regeneration

File: internal/router/device.go

Location: After device registration routes (around line 25)

Current Implementation: Need to check what device routes exist

Required Addition: Add route for token regeneration

ADD THIS ROUTE (find appropriate location with other device routes):

// Token regeneration endpoint (JWT authentication required)
devices.PUT("/:id/regenerate-token", jwtMiddleware, h.RegenerateDeviceToken)

Complete Context (token regeneration route added to existing device routes):

// Existing device management routes (unchanged)
devices.GET("", jwtMiddleware, cfg.DeviceHandler.ListDevices)
devices.GET("/:id", jwtMiddleware, cfg.DeviceHandler.GetDevice)
devices.PUT("/:id", jwtMiddleware, cfg.DeviceHandler.UpdateDevice)
devices.DELETE("/:id", jwtMiddleware, cfg.DeviceHandler.DeleteDevice)

// NEW: Token regeneration endpoint (JWT authentication required)
devices.PUT("/:id/regenerate-token", jwtMiddleware, cfg.DeviceHandler.RegenerateDeviceToken)

Verification: Run go build ./internal/router


1.5 Backend Handler - Token Regeneration

File: internal/handlers/devices.go

Location: After DeleteDevice function (after line 486)

Required Addition: Add new handler function for token regeneration

ADD THIS CODE:

func (h *DeviceHandler) RegenerateDeviceToken(c echo.Context) error {
	// Verify JWT authentication
	userID := c.Get("user_id").(string)
	userUUID, err := uuid.Parse(userID)
	if err != nil {
		return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid user ID"})
	}

	// Parse device ID from URL parameter
	deviceID, err := uuid.Parse(c.Param("id"))
	if err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
	}

	pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true}

	// Verify device exists and belongs to user
	device, err := h.db.GetDevice(c.Request().Context(), pgDeviceID)
	if err != nil {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
	}

	if device.UserID.Bytes != userUUID {
		return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
	}

	// Generate new auth token
	newToken, err := generateDeviceToken()
	if err != nil {
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
	}

	// Update device with new token
	updatedDevice, err := h.db.UpdateDeviceAuthToken(c.Request().Context(), database.UpdateDeviceAuthTokenParams{
		ID: pgDeviceID,
		AuthToken: pgtype.Text{String: newToken, Valid: true},
	})

	if err != nil {
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update token"})
	}

	// Return new token with device info
	syncEnabled := updatedDevice.SyncEnabled.Bool && updatedDevice.SyncEnabled.Valid
	autoSync := updatedDevice.AutoSync.Bool && updatedDevice.AutoSync.Valid
	syncFreq := int32(0)
	if updatedDevice.SyncFrequencyMinutes.Valid {
		syncFreq = updatedDevice.SyncFrequencyMinutes.Int32
	}

	// Build sync URLs with new token
	syncURLs := map[string]string{}
	baseURL := h.cfg.BaseURL

	switch updatedDevice.DeviceType {
	case "kobo":
		syncURLs["sync_url"] = fmt.Sprintf("%s/api/sync/kobo/%s", baseURL, newToken)
		syncURLs["markup"] = fmt.Sprintf("%s/api/sync/kobo/%s/markup", baseURL, newToken)
		syncURLs["bookmark"] = fmt.Sprintf("%s/api/sync/kobo/%s/bookmark", baseURL, newToken)
		syncURLs["init"] = fmt.Sprintf("%s/api/sync/kobo/%s/v1/initialization", baseURL, newToken)
	case "koreader":
		syncURLs["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", baseURL)
		syncURLs["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", baseURL)
		syncURLs["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", baseURL)
	}

	return c.JSON(http.StatusOK, map[string]interface{}{
		"message":     "Token regenerated successfully",
		"auth_token":  newToken,
		"device": DeviceInfo{
			ID:             updatedDevice.ID.Bytes,
			DeviceName:     updatedDevice.DeviceName,
			DeviceType:     updatedDevice.DeviceType,
			LastSync:       (*time.Time)(&updatedDevice.LastSync.Time),
			LastSeen:       (*time.Time)(&updatedDevice.LastSeen.Time),
			SyncEnabled:    syncEnabled,
			AutoSync:       autoSync,
			SyncFrequency:  syncFreq,
			CreatedAt:      updatedDevice.CreatedAt.Time,
			DeviceMetadata: updatedDevice.DeviceMetadata,
		},
		"sync_urls": syncURLs,
	})
}

Explanation:

  • Validates JWT token (user must be logged in)
  • Verifies device ownership (device belongs to user)
  • Generates new token using existing generateDeviceToken() helper
  • Updates devices.auth_token using new UpdateDeviceAuthToken query
  • Returns new token with device info and sync URLs
  • For Kobo: returns URL with token in path (Kobo never uses Bearer header)
  • For KOReader: returns token for Authorization header (KOReader never uses URL path token)

Verification:

  1. Run go build ./internal/handlers
  2. Run go test ./internal/handlers/... -v
  3. Test manually via Bruno collection

1.6 Frontend Template - Device Management UI

File: templates/devices.templ

Location: Multiple locations

Overview: After refactor (REFACTORING_PLAN.md Phase 1.3), templates use handlers.DeviceInfo directly - no conversion layer exists. Time fields are *time.Time (not strings).

Required Changes:

  1. Add auth_token field to handlers.DeviceInfo struct
  2. Add "Copy Sync URL" button for each device
  3. Add "Regenerate Token" button for each device
  4. Add JavaScript functions for copy and regenerate
  5. Update time formatting in templates (use .Format() method)

1.6.1 Add AuthToken to handlers.DeviceInfo Struct

File: internal/handlers/devices.go

Location: Line 78 (after DeviceMetadata field in DeviceInfo struct)

ADD THIS FIELD:

type DeviceInfo struct {
	ID             uuid.UUID       `json:"id"`
	DeviceName     string          `json:"device_name"`
	DeviceType     string          `json:"device_type"`
	LastSync       *time.Time      `json:"last_sync"`
	LastSeen       *time.Time      `json:"last_seen"`
	SyncEnabled    bool            `json:"sync_enabled"`
	AutoSync       bool            `json:"auto_sync"`
	SyncFrequency  int32           `json:"sync_frequency_minutes"`
	CreatedAt      time.Time       `json:"created_at"`
	DeviceMetadata json.RawMessage `json:"device_metadata,omitempty"`
	AuthToken      string          `json:"auth_token"` // NEW: Device API key for authentication
}

1.6.2 Update Device List Handlers

File: internal/handlers/devices.go

Location: GetDevicesData function (line 275-313) AND ListDevices function (line 255-267)

GetDevicesData - ADD FIELD TO RESPONSE (modify line 298-309):

deviceList = append(deviceList, DeviceInfo{
	ID:             device.ID.Bytes,
	DeviceName:     device.DeviceName,
	DeviceType:     device.DeviceType,
	LastSync:       (*time.Time)(&device.LastSync.Time),
	LastSeen:       (*time.Time)(&device.LastSeen.Time),
	SyncEnabled:    syncEnabled,
	AutoSync:       autoSync,
	SyncFrequency:  syncFreq,
	CreatedAt:      device.CreatedAt.Time,
	DeviceMetadata: device.DeviceMetadata,
	AuthToken:      device.AuthToken, // NEW: Include auth token
})

ListDevices - ADD FIELD TO RESPONSE (modify line around 262):

Find the loop that constructs deviceList[i] and add AuthToken field:

deviceList[i] = DeviceInfo{
	ID:             device.ID.Bytes,
	DeviceName:     device.DeviceName,
	DeviceType:     device.DeviceType,
	LastSync:       (*time.Time)(&device.LastSync.Time),
	LastSeen:       (*time.Time)(&device.LastSeen.Time),
	SyncEnabled:    syncEnabled,
	AutoSync:       autoSync,
	SyncFrequency:  syncFreq,
	CreatedAt:      device.CreatedAt.Time,
	DeviceMetadata: device.DeviceMetadata,
	AuthToken:      device.AuthToken, // NEW: Include auth token
}

1.6.3 Update Device Card Template

File: templates/devices.templ

Location: Template function signature (line 5) and device card (lines 46-98)

Required Changes:

  1. Update template signature to accept baseURL parameter (line 5):
templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []PendingRegistrationData, baseURL string) {
  1. REPLACE DEVICE CARD CONTENT (lines 46-98) with:
for _, device := range devices {
	<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
		<div class="flex items-start justify-between mb-4">
			<div class="text-4xl">
				if device.DeviceType == "koreader" {
					📖
				} else if device.DeviceType == "kobo" {
					📚
				} else if device.DeviceType == "web" {
					🌐
				} else {
					📱
				}
			</div>
			<div class="flex space-x-2">
				<button onclick="showShelfMappings('{ device.ID }')" class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
					📚
				</button>
				<button onclick="showDeviceSettings('{ device.ID }')" class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
					⚙️
				</button>
			</div>
		</div>
		<h3 class="text-lg font-semibold mb-1" style="color: var(--text-primary)">{ device.DeviceName }</h3>
		<p class="text-sm mb-4" style="color: var(--text-secondary)">{ device.DeviceType }</p>
		<div class="space-y-2 text-sm mb-4">
			<div class="flex justify-between">
				<span style="color: var(--text-secondary)">Sync Status</span>
				if device.SyncEnabled {
					<span style="color: var(--accent)"> Enabled</span>
				} else {
					<span style="color: var(--text-secondary)"> Disabled</span>
				}
			</div>
			<div class="flex justify-between">
				<span style="color: var(--text-secondary)">Last Sync</span>
				if device.LastSync != nil {
					<span style="color: var(--text-primary)">{ device.LastSync.Format("2006-01-02 15:04") }</span>
				} else {
					<span style="color: var(--text-primary)">Never</span>
				}
			</div>
			<div class="flex justify-between">
				<span style="color: var(--text-secondary)">Last Seen</span>
				if device.LastSeen != nil {
					<span style="color: var(--text-primary)">{ device.LastSeen.Format("2006-01-02 15:04") }</span>
				} else {
					<span style="color: var(--text-primary)">Never</span>
				}
			</div>
		</div>

		<!-- NEW: Sync URL & Token Management -->
		<div class="border-t pt-4" style="border-color: var(--border);">
			<p class="text-xs font-semibold mb-2" style="color: var(--text-secondary)">DEVICE SYNC CONFIGURATION</p>

			if device.DeviceType == "kobo" {
				<!-- Kobo: Copy Full Sync URL -->
				<div class="mb-3">
					<label class="block text-xs mb-1" style="color: var(--text-secondary)">Kobo Sync URL</label>
					<div class="flex space-x-2">
						<input
							type="text"
							id="sync-url-{ device.ID }"
							readonly
							value="{ fmt.Sprintf("%s/api/sync/kobo/%s", baseURL, device.AuthToken) }"
							class="flex-1 px-3 py-2 text-xs rounded border"
							style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
						/>
						<button
							onclick="copyToClipboard('{ fmt.Sprintf("%s/api/sync/kobo/%s", baseURL, device.AuthToken) }', 'Kobo sync URL', event)"
							class="px-3 py-2 text-xs rounded hover:opacity-80"
							style="background-color: var(--accent); color: white;"
						>
							📋 Copy
						</button>
					</div>
					<p class="text-xs mt-1" style="color: var(--text-secondary);">Paste this URL into Kobo's <code class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary);">api_endpoint</code> setting</p>
				</div>
			}

			if device.DeviceType == "koreader" {
				<!-- KOReader: Copy Auth Token -->
				<div class="mb-3">
					<label class="block text-xs mb-1" style="color: var(--text-secondary)">Auth Token (for plugin)</label>
					<div class="flex space-x-2">
						<input
							type="text"
							id="auth-token-{ device.ID }"
							readonly
							value="{ device.AuthToken }"
							class="flex-1 px-3 py-2 text-xs rounded border"
							style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border); font-family: monospace;"
						/>
						<button
							onclick="copyToClipboard('{ device.AuthToken }', 'Auth token', event)"
							class="px-3 py-2 text-xs rounded hover:opacity-80"
							style="background-color: var(--accent); color: white;"
						>
							📋 Copy
						</button>
					</div>
					<p class="text-xs mt-1" style="color: var(--text-secondary);">Enter this token in the KOReader plugin settings</p>
				</div>
			}

			<!-- Regenerate Token Button -->
			<button
				onclick="regenerateDeviceToken('{ device.ID }', event)"
				class="w-full px-3 py-2 text-xs rounded border hover:opacity-80"
				style="border-color: var(--border); color: var(--text-secondary); background-color: var(--bg-primary);"
			>
				🔄 Regenerate Token
			</button>
			<p class="text-xs mt-1" style="color: var(--text-secondary);">⚠️ Old token will immediately stop working</p>
		</div>
	</div>
}

Explanation:

  • Kobo devices: Show full sync URL with token in path
  • KOReader devices: Show auth token only (plugin handles Authorization header)
  • One-click copy buttons use clipboard API
  • Regenerate button requires confirmation
  • Clear setup instructions for each device type

Handler Update (internal/router/frontend.go:171):

err = templates.Devices(user, devices, pendingList, cfg.BaseURL).Render(c.Request().Context(), &buf)

Pass cfg.BaseURL to template instead of hardcoding URLs.

1.6.4 Add TypeScript Device Management

File: web/src/device-management.ts (CREATE NEW FILE)

Location: New TypeScript file for device management functions

CREATE THIS TYPESCRIPT:

// Device Management - Token copy and regeneration
// Procedural style with proper types (no OOP)

interface RegenerateTokenResponse {
	message: string;
	auth_token: string;
	device: {
		id: string;
		device_name: string;
		device_type: string;
		auth_token: string;
		sync_enabled: boolean;
		auto_sync: boolean;
		sync_frequency_minutes: number;
	};
	sync_urls?: {
		sync_url?: string;
		markup?: string;
		bookmark?: string;
		init?: string;
		progress?: string;
		metadata?: string;
		bookmarks?: string;
	};
}

// Copy sync URL or auth token to clipboard
function copyToClipboard(text: string, label: string): void {
	navigator.clipboard.writeText(text)
		.then(() => {
			const toast = (window as any).showToast;
			if (toast) {
				toast.success(`${label} copied to clipboard`);
			}
		})
		.catch((err: unknown) => {
			console.error('Failed to copy:', err);
			const toast = (window as any).showToast;
			if (toast) {
				toast.error('Failed to copy to clipboard');
			}
		});
}

// Regenerate device token with confirmation
function regenerateDeviceToken(deviceId: string, event: Event): void {
	const confirmation = '⚠️ This will revoke current token and generate a new one.\n\n' +
		'The old token will immediately stop working.\n\n' +
		'You will need to update your device configuration with new token.\n\n' +
		'Continue?';

	if (!confirm(confirmation)) {
		return;
	}

	const btn = event.target as HTMLButtonElement;
	const originalText = btn.innerHTML;
	btn.disabled = true;
	btn.innerHTML = '🔄 Regenerating...';

	fetch(`/api/devices/${deviceId}/regenerate-token`, {
		method: 'PUT',
		headers: {
			'Content-Type': 'application/json',
		}
	})
	.then((response: Response) => {
		if (!response.ok) {
			throw new Error('Failed to regenerate token');
		}
		return response.json() as Promise<RegenerateTokenResponse>;
	})
	.then((data: RegenerateTokenResponse) => {
		const toast = (window as any).showToast;
		if (toast) {
			toast.success('Token regenerated successfully - update your device config');
		}
		// Reload page to show new token
		setTimeout(() => location.reload(), 1500);
	})
	.catch((error: unknown) => {
		console.error('Error:', error);
		const toast = (window as any).showToast;
		if (toast) {
			toast.error('Failed to regenerate token');
		}
		if (btn) {
			btn.disabled = false;
			btn.innerHTML = originalText;
		}
	});
}

// Export functions for global access (called from template onclick attributes)
declare global {
	function copyToClipboard(text: string, label: string): void;
	function regenerateDeviceToken(deviceId: string): void;
}

window.copyToClipboard = copyToClipboard;
window.regenerateDeviceToken = regenerateDeviceToken;

Explanation:

  • Proper TypeScript type annotations
  • Interface for API response
  • Type assertions for window global
  • Procedural functions (no classes, no this)
  • Follows existing toast.ts pattern
  • Exports functions to window for template access

**Verification**:
1. Run `npm run build:ts` to compile TypeScript
2. Run `go build ./...`
3. Test in browser:
   - Click "Copy" button - should copy sync URL/token
   - Click "Regenerate" - should show confirmation
   - Confirm regeneration - should show success toast
   - Page should reload with new token

**Build Process**:
```bash
# Compile TypeScript to JavaScript
npm run build:ts

# Output: web/static/device-management.js
# Template references: <script src="/static/device-management.js"></script>

1.7 Bruno API Tests - Token Regeneration

File: bruno/devices/regenerate-token.bru

CREATE NEW FILE:

meta {
  name: Regenerate Device Token
  type: http
  seq: 1
}

put {
  url: {{base_url}}/api/devices/{{device_id}}/regenerate-token
  body: none
  auth: inherit
}

docs {
  ## Regenerate Device Token

  Regenerates auth token for a device, invalidating old token immediately.

  **Method:** PUT

  **Endpoint:** /api/devices/{device_id}/regenerate-token

  **Authentication:** Bearer token (JWT)

  **Path Parameters:**
  - `device_id` (string): Device UUID

  **Response:**
  - `message` (string): Success message
  - `auth_token` (string): New auth token
  - `device` (object): Updated device details
  - `sync_urls` (object): Device-specific sync URLs with new token

  **Status Codes:**
  - 200: Success
  - 401: Unauthorized
  - 403: Forbidden (device belongs to different user)
  - 404: Device not found
  - 500: Internal server error

  **Important Notes:**
  - Old token stops working immediately
  - Device must be updated with new token to resume syncing
  - No data loss - device ID remains same

  **Example Response:**
  ```json
  {
    "message": "Token regenerated successfully",
    "auth_token": "dev_abc123...",
    "device": {
      "id": "uuid-here",
      "device_name": "My Kobo Clara",
      "device_type": "kobo",
      "sync_enabled": true,
      "auto_sync": true,
      "sync_frequency_minutes": 5,
      "created_at": "2026-02-12T10:00:00Z",
      "device_metadata": "{...}"
    },
    "sync_urls": {
      "sync_url": "http://localhost:8765/api/sync/kobo/dev_new_token",
      "markup": "http://localhost:8765/api/sync/kobo/dev_new_token/markup",
      "bookmark": "http://localhost:8765/api/sync/kobo/dev_new_token/bookmark",
      "init": "http://localhost:8765/api/sync/kobo/dev_new_token/v1/initialization"
    }
  }

Important Notes:

  • Old token stops working immediately
  • Device must be updated with new token to resume syncing
  • No data loss - device ID remains same }

**File**: `bruno/devices/regenerate-token-unauthorized.bru`

**CREATE NEW FILE**:

```bruno
meta {
  name: Regenerate Device Token - Unauthorized
  type: http
  seq: 2
}

put {
  url: {{base_url}}/api/devices/{{device_id}}/regenerate-token
  body: none
  auth: none
}

docs {
  ## Regenerate Device Token - Unauthorized

  Tests that token regeneration requires authentication.

  **Expected Behavior:** Returns 401 Unauthorized when no Bearer token is provided

  **Status Codes:**
  - 401: Unauthorized (missing or invalid token)

  **Use Case:** Verify authentication is required for token regeneration
}

File: bruno/devices/regenerate-token-forbidden.bru

CREATE NEW FILE:

meta {
  name: Regenerate Device Token - Forbidden
  type: http
  seq: 3
}

put {
  url: {{base_url}}/api/devices/{{other_device_id}}/regenerate-token
  body: none
  auth: inherit 
}

docs {
  ## Regenerate Device Token - Forbidden

  Tests that users cannot regenerate tokens for devices belonging to other users.

  **Expected Behavior:** Returns 403 Forbidden when trying to regenerate token for another user's device

  **Status Codes:**
  - 403: Forbidden (device belongs to different user)

  **Use Case:** Verify authorization - users can only manage their own devices

  **Setup:** Use Bearer token from user A, try to regenerate token for user B's device
}

File: bruno/devices/regenerate-token-notfound.bru

CREATE NEW FILE:

meta {
  name: Regenerate Device Token - Not Found
  type: http
  seq: 4
}

put {
  url: {{base_url}}/api/devices/00000000-0000-0000-0000-000000000000/regenerate-token
  body: none
  auth: bearer
}

docs {
  ## Regenerate Device Token - Not Found

  Tests that token regeneration returns 404 for non-existent devices.

  **Expected Behavior:** Returns 404 Not Found when device UUID doesn't exist

  **Status Codes:**
  - 404: Device not found

  **Use Case:** Verify proper error handling for invalid device IDs

  **Setup:** Use all-zero UUID (guaranteed to not exist in database)
}

Verification: Test each request


1.8 Bruno API Tests - Update Kobo Sync

File: bruno/sync-kobo/sync-markup-url-token.bru

CREATE NEW FILE:

meta {
  name: Sync Markup - URL Path Token
  type: http
  seq: 1
}

post {
  url: {{base_url}}/sync/kobo/{{kobo_device_token}}/markup
  body: json
  auth: none
}

headers {
  Content-Type: application/json
}

body:json {
  {
    "ReadingSync": [
      {
        "ContentId": "book-uuid",
        "PercentRead": 45.6,
        "EntitlementId": "entitlement-id",
        "RemainingTimeMinutes": 120,
        "FirstReadTime": "2026-01-25T10:00:00Z",
        "LastModified": "2026-01-30T20:00:00Z"
      }
    ],
    "BookmarkSync": []
  }
}

docs {
  ## Sync Reading Progress - URL Path Token

  Synchronizes reading progress from Kobo device using token in URL path.

  **Method:** POST

  **Endpoint:** /sync/kobo/{kobo_device_token}/markup

  **Authentication:** URL path parameter (Kobo devices never send Bearer tokens)

  **Path Parameters:**
  - `kobo_device_token` (string): Device auth token from devices.auth_token

  **Request Body:**
  - `ReadingSync` (array): Reading progress data
  - `BookmarkSync` (array): Bookmarks and annotations

  **Response:** Success message

  **Status Codes:**
  - 200: Success
  - 401: Unauthorized
  - 403: Device sync disabled
  - 500: Internal server error

  **Important:** Kobo firmware requires token in URL path, cannot use Bearer header

  **Use Case:** Kobo devices syncing reading progress via stock firmware
}

File: bruno/sync-kobo/sync-bookmark-url-token.bru

CREATE NEW FILE:

meta {
  name: Sync Bookmark - URL Path Token
  type: http
  seq: 2
}

post {
  url: {{base_url}}/sync/kobo/{{kobo_device_token}}/bookmark
  body: json
  auth: none
}

headers {
  Content-Type: application/json
}

body:json {
  {
    "ContentId": "book-uuid",
    "BookmarkText": "Highlighted text",
    "BookmarkType": "annotation",
    "BookmarkTitle": "Chapter 3"
  }
}

docs {
  ## Sync Bookmark - URL Path Token

  Synchronizes bookmarks and annotations from Kobo device using token in URL path.

  **Method:** POST

  **Endpoint:** /sync/kobo/{kobo_device_token}/bookmark

  **Authentication:** URL path parameter (Kobo devices never send Bearer tokens)

  **Path Parameters:**
  - `kobo_device_token` (string): Device auth token from devices.auth_token

  **Request Body:**
  - `ContentId` (string): Book UUID
  - `BookmarkText` (string): Highlighted or annotated text
  - `BookmarkType` (string): Type (annotation, bookmark, note)
  - `BookmarkTitle` (string): Title for the bookmark

  **Response:** Success message

  **Status Codes:**
  - 200: Success
  - 401: Unauthorized
  - 403: Device sync disabled
  - 500: Internal server error

  **Important:** Kobo devices use URL path token exclusively, never Bearer header

  **Use Case:** Kobo devices syncing bookmarks and highlights via stock firmware
}

File: bruno/sync-kobo/get-library-url-token.bru

CREATE NEW FILE:

meta {
  name: Get Library - URL Path Token
  type: http
  seq: 3
}

get {
  url: {{base_url}}/sync/kobo/{{kobo_device_token}}/library
  body: none
  auth: none
}

docs {
  ## Get Kobo Library - URL Path Token

  Retrieves library metadata for Kobo device using token in URL path.

  **Method:** GET

  **Endpoint:** /sync/kobo/{kobo_device_token}/library

  **Authentication:** URL path parameter (Kobo devices never send Bearer tokens)

  **Path Parameters:**
  - `kobo_device_token` (string): Device auth token from devices.auth_token

  **Response:** Library metadata with book list

  **Status Codes:**
  - 200: Success (library metadata)
  - 401: Unauthorized
  - 403: Device sync disabled
  - 404: Device not found

  **Important:** Kobo devices use URL path token exclusively, never Bearer header

  **Use Case:** Kobo devices retrieving library information via stock firmware
}

File: bruno/sync-kobo/get-initialization-url-token.bru

CREATE NEW FILE:

meta {
  name: Kobo Initialization - URL Path Token
  type: http
  seq: 4
}

get {
  url: {{base_url}}/sync/kobo/{{kobo_device_token}}/v1/initialization
  body: none
  auth: none
}

docs {
  ## Kobo Initialization - URL Path Token

  Returns initialization data for Kobo device using token in URL path.

  **Method:** GET

  **Endpoint:** /sync/kobo/{kobo_device_token}/v1/initialization

  **Authentication:** URL path parameter (Kobo devices never send Bearer tokens)

  **Path Parameters:**
  - `kobo_device_token` (string): Device auth token from devices.auth_token

  **Response:** Initialization configuration and settings

  **Status Codes:**
  - 200: Success (initialization data)
  - 401: Unauthorized
  - 403: Device sync disabled
  - 404: Device not found

  **Important:** Kobo devices use URL path token exclusively, never Bearer header

  **Use Case:** Kobo devices initializing sync connection via stock firmware

  **Note:** This endpoint is called when Kobo first connects to sync server
}

KEEP EXISTING REQUESTS in bruno/sync-kobo/api.bru for API clients and testing (Bearer token works for non-Kobo clients)

Verification: Test both authentication methods work (Kobo uses URL path, API clients can use Bearer)

Phase 2: Kobo Integration

2.1 Kobo Setup Documentation

File: docs/user/devices/kobo-setup.md

Location: Full file rewrite needed

Current Documentation: Mentions serial number-based auth (outdated)

Required Update: Document API key in URL path approach

REPLACE ENTIRE FILE with:

# Kobo E-Reader Setup Guide

**Last Updated**: 2026-02-12
**Authentication Method**: API Key in URL path

This guide walks you through setting up your Kobo e-reader to sync with Bookhoard.

## Prerequisites

- Kobo e-reader (Kobo Clara, Libra, Forma, Sage, etc.)
- USB cable to connect Kobo to computer
- Bookhoard server running and accessible
- Kobo connected to same network as Bookhoard server

## Step 1: Find Your Kobo Serial Number

Your Kobo's serial number is used as the **device identifier** to uniquely identify your device.

1. On your Kobo, tap: **Settings****Device Information**
2. Look for **Serial Number** (format: `N1234567890123`)
3. Write down this serial number - you'll need it for registration

**Example**: `N7353019193781`

### Alternative: Find Serial Number via USB

1. Connect Kobo to computer via USB
2. Open `.adobe-digital-editions/` folder on Kobo
3. Open `device.xml` file
4. Find `<deviceSerial>` tag - this is your serial number

## Step 2: Register Device in Bookhoard

1. Open Bookhoard web interface in browser
2. Navigate to **Device Management**
3. Click ** Add New Device**
4. Fill in registration form:
   - **Device Name**: `My Kobo Clara` (or your device name)
   - **Device Type**: `Kobo E-Reader`
   - **Device Identifier**: Enter your Kobo serial number (e.g., `N7353019193781`)
5. Click **Register Device**

## Step 3: Approve Device

1. You'll see a pending registration for your Kobo
2. Click **Approve** button
3. Device is now registered and has an API key (auth token)

## Step 4: Copy Kobo Sync URL

After approval, your Kobo device card shows:

**Kobo Sync URL**:

http://192.168.1.100:8765/api/sync/kobo/dev_abc123def456...


1. Click **📋 Copy** button next to the URL
2. This URL contains your device's API key
3. Keep this URL handy - you'll need it in the next step

**Important**: This URL is unique to your device. Don't share it publicly.

## Step 5: Configure Kobo Sync

### Connect Kobo to Computer

1. Connect Kobo to computer via USB cable
2. Kobo will appear as a USB drive (named "KOBOeReader")
3. Open the drive on your computer

### Edit Kobo Configuration

1. Navigate to `.kobo/Kobo/` folder
2. Open `Kobo eReader.conf` file in text editor
3. Find or add this line:

```ini
api_endpoint=http://YOUR_IP:8765/api/sync/kobo/YOUR_TOKEN

Example: api_endpoint=http://192.168.1.100:8765/api/sync/kobo/dev_73530191_937812_3abc4def5...

  1. Save the file
  2. Eject Kobo drive safely
  3. Kobo will restart automatically

What is api_endpoint?

This setting tells Kobo where to send sync data. By setting it to Bookhoard, Kobo will sync with your server instead of Kobo's store.

Security Note: The API key in this URL is a revocable token. If compromised, you can regenerate it from Device Management.

Step 6: Configure OPDS Catalog (Optional)

To browse and download books wirelessly:

  1. In Bookhoard Device Management, find your Kobo device
  2. Note the Device ID (UUID) shown in device details
  3. On Kobo, add OPDS catalog:
    • Title: Bookhoard
    • URL: http://YOUR_IP:8765/opds/devices/YOUR_DEVICE_ID/catalog?token=YOUR_TOKEN

Example:

http://192.168.1.100:8765/opds/devices/550e8400-e29b-41d4-a716-4466554400000/catalog?token=dev_73530191_937812_3abc4def5...

Step 7: Test Sync

  1. Open a book on your Kobo
  2. Read a few pages
  3. Connect Kobo to WiFi
  4. Kobo will automatically sync progress to Bookhoard
  5. In Bookhoard, check Reading Progress - should show Kobo's progress

What Syncs Automatically:

  • Reading progress (page position, percentage)
  • Bookmarks
  • Highlights
  • Notes

Troubleshooting

Kobo Won't Sync

Problem: Kobo doesn't connect to Bookhoard

Solutions:

  1. Check api_endpoint in Kobo eReader.conf - ensure URL is correct
  2. Verify Kobo and Bookhoard are on same network
  3. Check Bookhoard server is running
  4. Ensure device sync is enabled in Bookhoard Device Management

Sync Fails with 401 Unauthorized

Problem: API key is invalid or device sync disabled

Solutions:

  1. Check Device Management - is sync enabled for this device?
  2. Regenerate token (click "🔄 Regenerate Token")
  3. Update api_endpoint in Kobo config with new token
  4. Eject and restart Kobo

Multiple Kobo Devices

Problem: Have multiple Kobos - each needs separate registration

Solution:

  • Each Kobo has unique serial number
  • Register each Kobo separately
  • Each gets unique API key
  • Configure each Kobo with its own sync URL

Regenerating Your Token

If your API key is compromised or lost:

  1. In Bookhoard Device Management, find your Kobo
  2. Click 🔄 Regenerate Token
  3. Confirm regeneration
  4. Copy new sync URL
  5. Update api_endpoint in Kobo config with new URL
  6. Eject and restart Kobo

Important: Old token stops working immediately. Update config promptly.

Network Setup

Bookhoard works best on local network:

  1. Find Bookhoard server IP:
    • Linux: hostname -I
    • Mac: System Settings → Network
    • Windows: Command Prompt → ipconfig
  2. Use this IP in sync URL: http://SERVER_IP:8765/...
  3. Ensure Kobo and server are on same network

Remote Access (Advanced)

For remote sync, use reverse proxy with HTTPS:

  1. Set up reverse proxy (nginx, Caddy)
  2. Enable SSL/TLS (Let's Encrypt)
  3. Configure firewall rules
  4. Use VPN for secure remote access

Warning: Without HTTPS, API key can be intercepted on public networks.

Security Considerations

API Key in URL

Risks:

  • API key visible in Kobo logs
  • API key visible in server logs
  • API key stored in plain text config file

Mitigations:

  • Use HTTPS (reverse proxy)
  • Keep on local network only
  • Regenerate token if compromised
  • Don't share sync URL publicly

Best Practices

  1. Local network only: Keep Bookhoard on home/office network
  2. Use VPN: For remote access, use VPN tunnel
  3. HTTPS required: If exposing publicly, use reverse proxy with SSL
  4. Regenerate tokens: Periodically rotate API keys
  5. Monitor logs: Check for unauthorized access attempts

What's Next?

Additional Resources


**Verification**:
1. Check documentation renders at `/docs` endpoint
2. Test search finds this page
3. Verify all code examples are accurate
4. Confirm troubleshooting section covers common issues

---

### 2.2 Kobo Sync Validation Tests

**File**: `cmd/server/tests/kobo_test.go`

**Location**: Update existing tests to use URL path token

**Current Tests**: Use Bearer token in Authorization header

**Required Updates**: Add tests for URL path authentication

**ADD THESE TESTS**:

```go
func TestKoboSyncWithURLPathToken(t *testing.T) {
	cfg := setupTestServer(t)
	defer cfg.teardown()

	// Register and approve Kobo device
	registrationResp := registerDevice(t, cfg, DeviceRegistration{
		DeviceName:       "Test Kobo",
		DeviceType:       "kobo",
		DeviceIdentifier: "N1234567890123",
	})

	approveResp := approveDevice(t, cfg, registrationResp.RegistrationID)
	require.True(t, approveResp.Approved)

	// Extract auth token
	authToken := approveResp.AuthToken
	require.NotEmpty(t, authToken)

	// Test sync using URL path token (no Authorization header)
	markupURL := fmt.Sprintf("%s/api/sync/kobo/%s/markup", cfg.server.URL, authToken)
	syncData := map[string]interface{}{
		"ReadingSync": []map[string]interface{}{
			{
				"ContentId":           "test-book-uuid",
				"PercentRead":          45.6,
				"EntitlementId":        "entitlement-123",
				"RemainingTimeMinutes": 120,
			},
		},
		"BookmarkSync": []interface{}{},
	}

	body, _ := json.Marshal(syncData)
	req := httptest.NewRequest("POST", markupURL, bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	// NOTE: No Authorization header - token in URL path

	w := httptest.NewRecorder()
	cfg.server.Echo.ServeHTTP(w, req)

	assert.Equal(t, http.StatusOK, w.Code)
}

func TestKoboSyncRoutesSupportBearerToken(t *testing.T) {
	cfg := setupTestServer(t)
	defer cfg.teardown()

	// Register and approve device
	registrationResp := registerDevice(t, cfg, DeviceRegistration{
		DeviceName:       "Test Kobo",
		DeviceType:       "kobo",
		DeviceIdentifier: "N1234567890123",
	})

	approveResp := approveDevice(t, cfg, registrationResp.RegistrationID)
	require.True(t, approveResp.Approved)

	authToken := approveResp.AuthToken

	// Test that Kobo sync routes accept Bearer token for API clients/automation
	// NOTE: Kobo devices will NEVER send Bearer tokens - they only use URL path
	// This test verifies the middleware supports Bearer tokens for other clients
	markupURL := fmt.Sprintf("%s/api/sync/kobo/%s/markup", cfg.server.URL, authToken)
	syncData := map[string]interface{}{
		"ReadingSync": []map[string]interface{}{
			{
				"ContentId":  "test-book-uuid",
				"PercentRead": 45.6,
			},
		},
		"BookmarkSync": []interface{}{},
	}

	body, _ := json.Marshal(syncData)
	req := httptest.NewRequest("POST", markupURL, bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken))

	w := httptest.NewRecorder()
	cfg.server.Echo.ServeHTTP(w, req)

	assert.Equal(t, http.StatusOK, w.Code)
}

Verification: Run go test ./cmd/server/tests/... -v -run TestKobo


Phase 3: OPDS Security

3.1 OPDS Router Enhancement

File: internal/router/opds.go

Current Implementation: Already has DeviceAuthMiddleware applied (line 12)

Required Addition: Add comment documenting query parameter support

UPDATE COMMENTS (lines 3-12):

// Register OPDS routes with device authentication
//
// Authentication Methods:
// 1. Authorization: Bearer {token} header (KOReader, API clients)
// 2. ?token={token} query parameter (Kobo OPDS catalog)
//
// Devices must use their devices.auth_token (generated during device registration)
//
// Kobo OPDS URL format: /opds/devices/{deviceId}/catalog?token={auth_token}
// KOReader OPDS URL format: /opds/devices/{deviceId}/catalog (uses Bearer header)
//
// Returns 401 Unauthorized if device token is missing, invalid, or device sync is disabled
func registerOPDSRoutes(cfg *Config) {
	e := cfg.Echo

	// Require device authentication for all OPDS endpoints
	// Middleware checks both Bearer header and ?token= query parameter
	opds := e.Group("/opds/devices")
	opds.Use(cfg.DeviceAuthMiddleware.Authenticate)
	opds.GET("/:deviceId/catalog", cfg.OPDSHandler.GetDeviceCatalog)
	opds.GET("/:deviceId/search", cfg.OPDSHandler.SearchDeviceCatalog)
	opds.GET("/:deviceId/nav", cfg.OPDSHandler.GetDeviceNavigation)
	opds.GET("/:deviceId/download/:bookId", cfg.OPDSHandler.DownloadBook)
	opds.GET("/:deviceId/cover/:bookId", cfg.OPDSHandler.GetCoverImage)
	opds.GET("/:deviceId/formats/:bookId", cfg.OPDSHandler.ListFormats)
}

Explanation: No code changes needed - middleware already supports query parameter (see 1.2). Just documenting the feature.


3.2 Bruno API Tests - OPDS Authentication

The following Bruno request files test OPDS authentication with both Bearer tokens and query parameters.

File: bruno/opds/Get Device Catalog - Bearer.bru

CREATE NEW FILE:

meta {
  name: Get Device Catalog (Bearer Token)
  type: http
  seq: 1
}

get {
  url: {{opds_base_url}}/devices/{{device_id}}/catalog
}

docs {
  ## Get Device OPDS Catalog - Bearer Token

  Retrieves OPDS catalog for a device using Bearer token authentication.

  **Method:** GET

  **Endpoint:** /opds/devices/{device_id}/catalog

  **Authentication:** Bearer token (for KOReader and API clients)

  **Path Parameters:**
  - `device_id` (string): Device UUID

  **Response:** OPDS Atom feed catalog

  **Status Codes:**
  - 200: Success (OPDS feed)
  - 401: Unauthorized
  - 403: Device sync disabled
  - 404: Device not found

  **Use Case:** KOReader devices and API clients accessing OPDS catalog

  **Authentication Method:** Bearer token in Authorization header
}

File: bruno/opds/Get Device Catalog - Query Token.bru

CREATE NEW FILE:

meta {
  name: Get Device Catalog (Query Token)
  type: http
  seq: 2
}

get {
  url: {{opds_base_url}}/devices/{{device_id}}/catalog?token={{device_token}}
}

docs {
  ## Get Device OPDS Catalog - Query Token

  Retrieves OPDS catalog for a device using query parameter authentication.

  **Method:** GET

  **Endpoint:** /opds/devices/{device_id}/catalog?token={device_token}

  **Authentication:** Query parameter (for Kobo devices)

  **Path Parameters:**
  - `device_id` (string): Device UUID

  **Query Parameters:**
  - `token` (string): Device auth token

  **Response:** OPDS Atom feed catalog

  **Status Codes:**
  - 200: Success (OPDS feed)
  - 401: Unauthorized
  - 403: Device sync disabled
  - 404: Device not found

  **Use Case:** Kobo devices accessing OPDS catalog (easier to configure in Kobo settings)

  **Authentication Method:** Token in URL query parameter

  **Important:** Token visible in Kobo logs and server logs
}

File: bruno/opds/Get Device Catalog - No Auth.bru

CREATE NEW FILE:

meta {
  name: Get Device Catalog (No Authentication)
  type: http
  seq: 3
}

get {
  url: {{opds_base_url}}/devices/{{device_id}}/catalog
}

docs {
  ## Get Device OPDS Catalog - No Authentication

  Tests that OPDS catalog requires authentication.

  **Expected Behavior:** Returns 401 Unauthorized when no token provided

  **Status Codes:**
  - 401: Unauthorized

  **Use Case:** Verify authentication is required for OPDS access

  **Testing:** Ensures middleware properly rejects unauthenticated requests
}

File: bruno/opds/Get Device Catalog - Invalid Token.bru

CREATE NEW FILE:

meta {
  name: Get Device Catalog (Invalid Token)
  type: http
  seq: 4
}

get {
  url: {{opds_base_url}}/devices/{{device_id}}/catalog?token=invalid_token_12345
}

docs {
  ## Get Device OPDS Catalog - Invalid Token

  Tests that OPDS catalog rejects invalid tokens.

  **Expected Behavior:** Returns 401 Unauthorized when invalid token provided

  **Status Codes:**
  - 401: Unauthorized

  **Use Case:** Verify proper token validation

  **Testing:** Ensures middleware validates token format and database lookup
}

File: bruno/opds/Download Book - Bearer.bru

CREATE NEW FILE:

meta {
  name: Download Book (Bearer Token)
  type: http
  seq: 5
}

get {
  url: {{opds_base_url}}/devices/{{device_id}}/download/{{book_id}}
}

docs {
  ## Download Book - Bearer Token

  Downloads a book file using Bearer token authentication.

  **Method:** GET

  **Endpoint:** /opds/devices/{device_id}/download/{book_id}

  **Authentication:** Bearer token (for KOReader and API clients)

  **Path Parameters:**
  - `device_id` (string): Device UUID
  - `book_id` (string): Book UUID

  **Response:** Binary ebook file (EPUB, KEPUB, etc.)

  **Status Codes:**
  - 200: Success (binary file)
  - 401: Unauthorized
  - 403: Device sync disabled
  - 404: Book or device not found

  **Use Case:** KOReader devices and API clients downloading books

  **Authentication Method:** Bearer token in Authorization header
}

File: bruno/opds/Download Book - Query Token.bru

CREATE NEW FILE:

meta {
  name: Download Book (Query Token)
  type: http
  seq: 6
}

get {
  url: {{opds_base_url}}/devices/{{device_id}}/download/{{book_id}}?token={{device_token}}
}

docs {
  ## Download Book - Query Token

  Downloads a book file using query parameter authentication.

  **Method:** GET

  **Endpoint:** /opds/devices/{device_id}/download/{book_id}?token={device_token}

  **Authentication:** Query parameter (for Kobo devices)

  **Path Parameters:**
  - `device_id` (string): Device UUID
  - `book_id` (string): Book UUID

  **Query Parameters:**
  - `token` (string): Device auth token

  **Response:** Binary ebook file (EPUB, KEPUB, etc.)

  **Status Codes:**
  - 200: Success (binary file)
  - 401: Unauthorized
  - 403: Device sync disabled
  - 404: Book or device not found

  **Use Case:** Kobo devices downloading books via OPDS

  **Authentication Method:** Token in URL query parameter

  **Important:** Token visible in Kobo logs and server logs
}

Verification: Test both authentication methods work for OPDS access


Phase 4: Testing & Documentation

4.1 Security Documentation

File: docs/user/devices/security.md

CREATE NEW FILE:

# Device Authentication Security

**Last Updated**: 2026-02-12

This document explains security considerations for device authentication in Bookhoard.

## Authentication Methods

### Kobo E-Readers: API Key in URL Path

**Method**: Token embedded in URL: `/api/sync/kobo/{token}/markup`

**Pros**:
- ✅ Works with stock Kobo firmware (no jailbreak)
- ✅ Simple one-line configuration
- ✅ Proven approach (used by Komga)
- ✅ Per-device revocable tokens

**Cons**:
- ❌ Token visible in Kobo logs
- ❌ Token visible in server logs
- ❌ Token stored in plain text config

**Security Level**: Medium - acceptable for self-hosted use on trusted networks

### KOReader: Bearer Token in Header

**Method**: `Authorization: Bearer {token}` header

**Pros**:
- ✅ Cryptographically secure random UUIDs
- ✅ Not visible in URLs (only in headers)
- ✅ Standard authentication method
- ✅ Per-device revocable tokens

**Cons**:
- ❌ Requires plugin installation
- ❌ Token stored in plugin settings

**Security Level**: High - suitable for most deployments

### OPDS: Both Methods Supported

**Methods**:
- Query parameter: `?token={token}` (Kobo)
- Bearer header: `Authorization: Bearer {token}` (KOReader)

**Security Level**: Depends on which method is used

## Threat Model

### Assumptions

1. **Trusted Network**: Users run Bookhoard on home/office network
2. **Self-Hosted**: No public cloud exposure
3. **Technical Users**: Capable of setting up VPN, reverse proxy
4. **Data Privacy**: Books are personal, not state secrets

### Attack Scenarios

#### 1. Local Network Eavesdropping

**Attacker**: Devices on same WiFi network

**Risk**: Medium
- Can intercept traffic if no HTTPS
- Can see API key in URLs for Kobo

**Mitigation**:
- Use reverse proxy with HTTPS
- Keep on trusted network
- Use WPA3 encryption for WiFi

#### 2. Device Theft

**Attacker**: Physical access to Kobo/KOReader device

**Risk**: Low
- API key stored in device config
- Attacker can sync until token regenerated

**Mitigation**:
- Regenerate token if device lost
- Enable device approval (already implemented)
- Monitor logs for unauthorized access

#### 3. Server Compromise

**Attacker**: Access to Bookhoard server

**Risk**: High (but out of scope)
- Attacker has database access
- All tokens compromised

**Mitigation**:
- Keep server updated
- Use strong passwords
- Limit network exposure
- Regular backups

#### 4. Token Leakage via Logs

**Attacker**: Access to server logs or Kobo logs

**Risk**: Medium
- API keys visible in logs
- Historical keys may be exposed

**Mitigation**:
- Use log scrubbing (future enhancement)
- Limit log retention
- Regenerate tokens periodically

## Security Best Practices

### For Kobo Users

1. **Local Network Only**
   - Don't expose Bookhoard publicly
   - Use WiFi with WPA3 encryption
   - Consider guest network isolation

2. **HTTPS for Remote Access**
   - Set up reverse proxy (nginx, Caddy)
   - Use Let's Encrypt for SSL certificates
   - Enforce HTTPS redirection

3. **Token Management**
   - Regenerate token if device lost
   - Regenerate token periodically (monthly)
   - Don't share sync URL publicly

4. **Monitoring**
   - Check Device Management regularly
   - Look for unknown devices
   - Review server logs for suspicious activity

### For KOReader Users

1. **Plugin Security**
   - Only install official Bookhoard plugin
   - Verify plugin source
   - Keep plugin updated

2. **Token Storage**
   - Plugin stores token in settings file
   - Keep plugin files private
   - Don't share plugin config

3. **Network Security**
   - Same as Kobo (local network, HTTPS)
   - Bearer token slightly more secure than URL token

### For All Users

#### Network Setup

**Recommended**: Local network only

Kobo/KOReader ← WiFi → Router ← WiFi → Bookhoard Server


**Advanced**: VPN for remote access

Kobo/KOReader ← VPN → Internet ← VPN → Bookhoard Server


**Not Recommended**: Direct public exposure

Kobo/KOReader ← Internet → Bookhoard Server (INSECURE)


#### Reverse Proxy Setup (HTTPS)

**Using Caddy**:

bookhoard.example.com { reverse_proxy localhost:8765 encode gzip }


**Using nginx**:
```nginx
server {
    listen 443 ssl;
    server_name bookhoard.example.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://localhost:8765;
        proxy_set_header Host $host;
    }
}

Firewall Rules

UFW (Linux):

# Allow local network
sudo ufw allow from 192.168.1.0/24 to any port 8765

# Block public access
sudo ufw deny 8765

pfSense / OPNsense:

  • Create firewall rule: Allow from local network
  • Block WAN access to port 8765

Token Regeneration

When to Regenerate

  • Device lost or stolen
  • Token shared accidentally
  • Periodic rotation (monthly/quarterly)
  • Suspicious activity in logs

How to Regenerate

  1. Open Bookhoard Device Management
  2. Find device in list
  3. Click "🔄 Regenerate Token"
  4. Confirm regeneration
  5. Copy new token/sync URL
  6. Update device configuration

Impact

  • Old token stops working immediately
  • Device must update config to resume syncing
  • No data loss - device ID remains the same

Comparison with Other Systems

Calibre

  • Auth: Basic Auth (username/password)
  • Pros: Familiar, works everywhere
  • Cons: Credentials sent with every request

Komga

  • Auth: API key in URL (same as Bookhoard Kobo)
  • Pros: Simple, proven
  • Cons: Token visible in logs

Bookhoard

  • Kobo: API key in URL (matches Komga)
  • KOReader: Bearer token (more secure)
  • Pros: Flexible, revocable, per-device
  • Cons: Token visible in logs (Kobo only)

Compliance & Privacy

GDPR Considerations

  • Reading progress = personal data
  • Device tokens = access credentials
  • Users can export/delete data
  • Tokens can be revoked

Data Minimization

  • Only sync required data
  • No unnecessary metadata
  • Tokens don't reveal reading habits

Future Enhancements

Planned

  • Log scrubbing (hide tokens in logs)
  • Token expiration (auto-rotate tokens)
  • Device IP whitelisting
  • Biometric authentication (device unlock)

Not Planned

  • OAuth/OIDC (overkill for self-hosted)
  • Client certificates (too complex)
  • Hardware security keys (not supported by Kobo)

FAQs

Is API key auth secure enough?

Answer: Yes, for self-hosted use on trusted networks. Same security model as Komga, widely used in community.

Should I use HTTPS?

Answer: Highly recommended. Without HTTPS, API key can be intercepted on public networks.

What if my Kobo is stolen?

Answer: Regenerate token immediately. Old token stops working, thief loses access.

Can I use VPN?

Answer: Yes, VPN is recommended for remote access. Encrypts all traffic.

How often should I regenerate tokens?

Answer: Monthly if security-conscious. When device lost. Never if unconcerned.

Additional Resources


**Verification**:
1. Check documentation renders at `/docs` endpoint
2. Verify search finds this page
3. Test all code examples
4. Confirm security advice is sound

---

## Summary of Changes

### Files Modified

1. **Database** (`internal/database/queries/queries.sql`)
   - Added: `UpdateDeviceAuthToken` query

2. **Middleware** (`internal/middleware/device_auth.go`)
   - Enhanced: `Authenticate` function to support URL path + query param

3. **Router** (`internal/router/sync.go`)
   - Changed: Kobo routes to `/api/sync/kobo/:token`

4. **Router** (`internal/router/device.go`)
   - Added: Route for `PUT /:id/regenerate-token`

 5. **Handler** (`internal/handlers/devices.go`)
     - Added: `RegenerateDeviceToken` function
     - Added: `AuthToken` field to `DeviceInfo` struct (breaking change: all clients must accept this field)
     - Modified: `GetDevicesData`, `ListDevices` to include `auth_token`

6. **Template** (`templates/devices.templ`)
   - Added: `AuthToken` field to `DeviceData` struct
   - Added: Copy sync URL button
   - Added: Regenerate token button
   - Added: JavaScript functions for copy/regenerate

7. **Tests** (`cmd/server/tests/kobo_test.go`)
   - Added: URL path token tests
   - Updated: Bearer token tests

8. **Documentation** (`docs/user/devices/kobo-setup.md`)
   - Rewritten: API key in URL approach

9. **Documentation** (`docs/user/devices/security.md`)
   - Created: Security considerations document

 10. **Bruno** (`bruno/devices/regenerate-*.bru`)
     - Created: Token regeneration tests (success, unauthorized, forbidden, not found)

 11. **Bruno** (`bruno/sync-kobo/api.bru`)
     - Added: URL path token tests for Kobo sync

 12. **Bruno** (`bruno/opds/*-Bearer.bru`, `*-Query Token.bru`, `*-No Auth.bru`, `*-Invalid Token.bru`)
     - Created: OPDS authentication tests for both Bearer and query parameter methods

### Files Created

- `docs/user/devices/security.md`
- `bruno/devices/regenerate-token.bru`
- `bruno/devices/regenerate-token-unauthorized.bru`
- `bruno/devices/regenerate-token-forbidden.bru`
- `bruno/devices/regenerate-token-notfound.bru`
- `bruno/opds/Get Device Catalog - Bearer.bru`
- `bruno/opds/Get Device Catalog - Query Token.bru`
- `bruno/opds/Get Device Catalog - No Auth.bru`
- `bruno/opds/Get Device Catalog - Invalid Token.bru`
- `bruno/opds/Download Book - Bearer.bru`
- `bruno/opds/Download Book - Query Token.bru`

### Testing Checklist

- [ ] Run `go build ./...` - all packages compile
- [ ] Run `bash scripts/verify-guidelines.sh` - no errors
- [ ] Run `go test ./... -v` - all tests pass
- [ ] Test token regeneration in browser
- [ ] Test copy sync URL button
- [ ] Test Kobo sync with URL path token
- [ ] Test KOReader sync with Bearer token
- [ ] Test OPDS with query parameter
- [ ] Test OPDS with Bearer header
- [ ] Verify documentation renders at `/docs`
- [ ] Verify search finds new docs

---

**End of Implementation Document**

---

## Updates Applied (2026-02-12)

Based on code review analysis, the following fixes were applied to this document:

### Fixed Issues

1. **AuthToken Field Addition** (Section 1.6.1)
   - Added `AuthToken` field to `DeviceInfo` struct
   - Breaking change (Option A): All clients must accept this field

2. **ListDevices Handler Update** (Section 1.6.2)
   - Changed from "Do the same" to showing complete code modification
   - Added explicit code block for `ListDevices` function modification
   - Renamed section from "Update Device List Handler" to "Update Device List Handlers"

3. **goto Label Clarification** (Section 1.2)
   - Updated explanation to clarify that `validateDevice:` label is in the kept section
   - Added reference to line 221 in complete function
   - Made it explicit that lines 62-270 remain unchanged

4. **Time Formatting** (Section 1.6.3)
   - Updated template code to use `.Format("2006-01-02 15:04")` method
   - Fixed both `LastSync` and `LastSeen` field display
   - Both fields now properly format `*time.Time` values

### Remaining User Decisions

- ✅ Template type checking: Already fixed by user
- ✅ Bruno test variables: User confirmed they exist in environment
- ✅ Breaking change on AuthToken: User approved Option A

**Next Steps**:
1. Review this document completely
2. Ask questions if anything is unclear
3. Begin Phase 1 implementation (database query first)
4. Test after each file change
5. Commit changes with clear messages
6. Run full test suite before declaring complete

**Estimated Time**: 4-5 hours for full implementation (including testing)