- Add IMPLEMENTATION_EXACT.md with exact code changes for all phases
- Update IMPLEMENTATION_PLAN.md with clarifications on two-field approach:
- device_identifier: Serial number (Kobo) or UUID (KOReader)
- auth_token: Auto-generated API key for authentication
- Resolve all user questions with ✅ marked decisions
- Add verification steps for documentation accuracy
- Document Kobo vs KOReader registration workflow differences
- Add SQL query for token regeneration (UpdateDeviceAuthToken)
- Include TypeScript device management code
- Add Bruno API test files for all new endpoints
- Update Kobo setup documentation for URL path token approach
62 KiB
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
- Phase 1: Enhanced Authentication (Week 1)
- Database Query Addition
- Middleware Enhancement
- Router Updates
- Backend Handler Addition
- Frontend Template Updates
- Bruno API Tests
- Phase 2: Kobo Integration (Week 1-2)
- Documentation Updates
- Test Updates
- Phase 3: OPDS Security (Week 2-3)
- Router Enhancement
- Bruno API Tests
- 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 queryinternal/middleware/device_auth.go- Support URL path tokeninternal/router/sync.go- Update Kobo routesinternal/router/device.go- Add regenerate token routeinternal/handlers/devices.go- Add regenerate token handlertemplates/devices.templ- Add copy/regenerate UIbruno/sync-kobo/api.bru- Add URL path token requestsbruno/devices/regenerate-*.bru- New token regeneration test filesbruno/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 *;
-- name: RevokeDevice :exec
UPDATE devices
SET
auth_token = NULL,
sync_enabled = false,
updated_at = NOW()
WHERE id = $1;
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 validateDeviceis used here as a clean way to jump to common validation code after finding a device- 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
KEEP THE REST OF THE FUNCTION THE SAME (lines 62-108 remain unchanged)
Complete Function After Changes:
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:
- Run
go build ./internal/middleware - Run
go test ./internal/middleware/... -v - 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
:tokenallows 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 50)
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 (assuming placement after device registration routes):
// Device registration endpoints
devices.POST("/register", h.InitiateRegistration)
devices.POST("/approve/:registration_id", jwtMiddleware, h.ApproveDevice)
devices.POST("/reject/:registration_id", jwtMiddleware, h.RejectDevice)
devices.GET("/pending", jwtMiddleware, h.ListPendingRegistrations)
// Device management endpoints
devices.GET("", jwtMiddleware, h.ListDevices)
devices.GET("/:id", jwtMiddleware, h.GetDevice)
devices.PUT("/:id", jwtMiddleware, h.UpdateDevice)
devices.DELETE("/:id", jwtMiddleware, h.DeleteDevice)
// Token regeneration endpoint (JWT authentication required)
devices.PUT("/:id/regenerate-token", jwtMiddleware, h.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_tokenusing newUpdateDeviceAuthTokenquery - 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:
- Run
go build ./internal/handlers - Run
go test ./internal/handlers/... -v - Test manually via Bruno collection
1.6 Frontend Template - Device Management UI
File: templates/devices.templ
Location: Multiple locations
Required Changes:
- Add
auth_tokenfield toDeviceDatastruct - Add "Copy Sync URL" button for each device
- Add "Regenerate Token" button for each device
- Add JavaScript functions for copy and regenerate
1.6.1 Update DeviceData Struct
Location: Find DeviceData struct (near top of file)
Current struct (lines vary, find structure):
type DeviceData struct {
ID string
DeviceName string
DeviceType string
LastSync string
LastSeen string
SyncEnabled bool
AutoSync bool
SyncFrequency int
CreatedAt string
DeviceMetadata json.RawMessage
}
ADD FIELD to struct:
type DeviceData struct {
ID string
DeviceName string
DeviceType string
LastSync string
LastSeen string
SyncEnabled bool
AutoSync bool
SyncFrequency int
CreatedAt string
DeviceMetadata json.RawMessage
AuthToken string // NEW: Device API key for authentication
}
1.6.2 Update Device List Handler
File: internal/handlers/devices.go
Location: GetDevicesData function (line 275-313)
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
})
Do the same in ListDevices function (line 255-267).
1.6.3 Update Device Card Template
File: templates/devices.templ
Location: Lines 46-98 (device card in grid)
Current Implementation: Device card shows device info and settings buttons
Required Addition: Add buttons for copy sync URL and regenerate token
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 != "" {
<span style="color: var(--text-primary)">{ device.LastSync }</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 != "" {
<span style="color: var(--text-primary)">{ device.LastSeen }</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("http://YOUR_IP:8765/api/sync/kobo/%s", 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("http://YOUR_IP:8765/api/sync/kobo/%s", device.AuthToken) }', 'Kobo sync URL')"
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')"
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 }')"
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
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): 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
windowglobal - Procedural functions (no classes, no
this) - Follows existing
toast.tspattern - Exports functions to
windowfor 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 the auth token for a device, invalidating the 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 the 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_urls": {
"sync_url": "http://localhost:8765/api/sync/kobo/dev_new_token",
"markup": "http://localhost:8765/api/sync/kobo/dev_new_token/markup"
}
}
}
**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:
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...
4. Save the file
5. Eject Kobo drive safely
6. 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
### Local Network (Recommended)
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?
- [Configure OPDS for wireless book delivery](#step-6-configure-opds-catalog-optional)
- [Setup auto-sync](#step-7-test-sync)
- [Troubleshooting](#troubleshooting)
- [Security guide](./security.md)
## Additional Resources
- [Kobo Official Firmware](https://www.kobo.com/firmware)
- [Kobo Custom Firmware](https://www.mobileread.com/forums/showthread.php?t=273030)
- [Bookhoard Device Management](../../user/devices/)
- [Bookhoard Security Guide](./security.md)
Verification:
- Check documentation renders at
/docsendpoint - Test search finds this page
- Verify all code examples are accurate
- 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:
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
- Open Bookhoard Device Management
- Find device in list
- Click "🔄 Regenerate Token"
- Confirm regeneration
- Copy new token/sync URL
- 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
- 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**
**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)