experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(server): add single-user mode support

- Create config.js with loadConfig() for mode detection
- Create auth.js with middleware factory (single-user/multi-user)
- Update db.js with getProfileDir() for mode-aware path logic
- Update index.js to use auth middleware factory
- Skip multi-user migrations/backups in single-user mode

Single-user mode path: DATA_DIR/profiles/{profileId}/
Multi-user mode path: DATA_DIR/{userId}/profiles/{profileId}/

Environment variables:
- SINGLE_USER_MODE=true to enable
- SINGLE_USER_ID=<id> (default: 'default')
- SINGLE_USER_TOKEN=<token> for optional auth

docs(server): add ARCHITECTURE.md for portability abstractions

+337 -38
+147
backend/server/ARCHITECTURE.md
··· 1 + # Server Architecture 2 + 3 + ## Overview 4 + 5 + The Peek sync server is a Node.js/Hono application that supports both multi-user (hosted) and single-user (self-hosted) deployments. It uses abstraction layers for SQL and storage to enable future migration to different backends. 6 + 7 + ## Abstraction Layers 8 + 9 + ### SQL Abstraction (`sql/`) 10 + 11 + Abstracts database operations to support different SQLite backends: 12 + 13 + ``` 14 + sql/ 15 + ├── types.js # SqlAdapter and SqlAdapterFactory interfaces 16 + ├── better-sqlite3-adapter.js # Default adapter for Node.js 17 + └── index.js # Factory that selects adapter based on SQL_ADAPTER env 18 + ``` 19 + 20 + **Interface:** 21 + ```javascript 22 + interface SqlAdapter { 23 + exec(sql: string): void; 24 + run(sql: string, params?: unknown[]): { changes: number, lastInsertRowid?: number }; 25 + get<T>(sql: string, params?: unknown[]): T | null; 26 + all<T>(sql: string, params?: unknown[]): T[]; 27 + transaction<T>(fn: () => T): T; 28 + close(): void; 29 + } 30 + ``` 31 + 32 + **Future adapters:** 33 + - `do-sqlite-adapter.js` - For Cloudflare Durable Objects (TODO) 34 + 35 + ### Storage Abstraction (`storage/`) 36 + 37 + Abstracts image/blob storage to support different backends: 38 + 39 + ``` 40 + storage/ 41 + ├── types.js # StorageAdapter interface 42 + ├── filesystem-adapter.js # Default adapter for local filesystem 43 + └── index.js # Factory that selects adapter based on STORAGE_BACKEND env 44 + ``` 45 + 46 + **Interface:** 47 + ```javascript 48 + interface StorageAdapter { 49 + put(key: string, data: Buffer, metadata?: object): Promise<void>; 50 + putSync(key: string, data: Buffer, metadata?: object): void; 51 + get(key: string): Promise<Buffer | null>; 52 + getSync(key: string): Buffer | null; 53 + delete(key: string): Promise<void>; 54 + deleteSync(key: string): void; 55 + exists(key: string): Promise<boolean>; 56 + existsSync(key: string): boolean; 57 + list(prefix?: string): Promise<string[]>; 58 + } 59 + ``` 60 + 61 + **Future adapters:** 62 + - `r2-adapter.js` - For Cloudflare R2 (TODO) 63 + - `s3-adapter.js` - For S3-compatible storage (TODO) 64 + 65 + ## User Modes 66 + 67 + ### Multi-User Mode (Default) 68 + 69 + Standard hosted deployment with user isolation: 70 + 71 + ``` 72 + DATA_DIR/ 73 + ├── system.db # User registry 74 + ├── {userId}/ 75 + │ └── profiles/ 76 + │ └── {profileId}/ 77 + │ ├── datastore.sqlite 78 + │ └── images/ 79 + └── backups/ 80 + ``` 81 + 82 + - Authentication via API key (SHA-256 hash lookup in system.db) 83 + - Full user/profile management endpoints 84 + - Data isolation between users 85 + 86 + ### Single-User Mode 87 + 88 + Simplified self-hosted deployment: 89 + 90 + ``` 91 + DATA_DIR/ 92 + └── profiles/ 93 + └── {profileId}/ 94 + ├── datastore.sqlite 95 + └── images/ 96 + ``` 97 + 98 + **Enable with:** 99 + ```bash 100 + SINGLE_USER_MODE=true 101 + SINGLE_USER_TOKEN=your-secret-token # Optional bearer token 102 + SINGLE_USER_ID=default # Optional, defaults to 'default' 103 + ``` 104 + 105 + **Simplifications:** 106 + - No system.db (no user registry) 107 + - No API key hashing (simple token comparison) 108 + - No userId in paths 109 + - User management endpoints disabled 110 + 111 + ## Configuration 112 + 113 + | Variable | Default | Description | 114 + |----------|---------|-------------| 115 + | `PORT` | 3000 | Server port | 116 + | `DATA_DIR` | `./data` | Data directory | 117 + | `SQL_ADAPTER` | `better-sqlite3` | SQL adapter to use | 118 + | `STORAGE_BACKEND` | `filesystem` | Storage backend to use | 119 + | `SINGLE_USER_MODE` | `false` | Enable single-user mode | 120 + | `SINGLE_USER_TOKEN` | (none) | Bearer token for single-user auth | 121 + | `SINGLE_USER_ID` | `default` | User ID in single-user mode | 122 + 123 + ## Files 124 + 125 + | File | Purpose | 126 + |------|---------| 127 + | `index.js` | Hono app, routes, middleware | 128 + | `db.js` | Database operations, connection pool | 129 + | `users.js` | User/profile management (system.db) | 130 + | `backup.js` | Backup/restore functionality | 131 + | `config.js` | Configuration loading | 132 + | `auth.js` | Authentication middleware factory | 133 + | `sql/` | SQL abstraction layer | 134 + | `storage/` | Storage abstraction layer | 135 + 136 + ## Testing 137 + 138 + ```bash 139 + # Unit tests (110 tests) 140 + yarn server:test 141 + 142 + # Sync E2E tests (13 tests) 143 + yarn test:sync:e2e 144 + 145 + # Single-user mode tests 146 + SINGLE_USER_MODE=true yarn server:test 147 + ```
+90
backend/server/auth.js
··· 1 + /** 2 + * Authentication Middleware Factory 3 + * 4 + * Creates appropriate auth middleware based on server configuration. 5 + * - Multi-user mode: API key authentication via users module 6 + * - Single-user mode: Optional token authentication 7 + */ 8 + 9 + const users = require("./users"); 10 + 11 + /** 12 + * @typedef {import('./config').ServerConfig} ServerConfig 13 + * @typedef {import('./config').SingleUserConfig} SingleUserConfig 14 + */ 15 + 16 + /** 17 + * Create authentication middleware based on configuration. 18 + * 19 + * @param {ServerConfig} config - Server configuration 20 + * @returns {function} Hono middleware function 21 + */ 22 + function createAuthMiddleware(config) { 23 + if (config.mode === "single-user") { 24 + return singleUserMiddleware(config.singleUser); 25 + } 26 + return multiUserMiddleware(); 27 + } 28 + 29 + /** 30 + * Single-user authentication middleware. 31 + * - If token is configured, requires Bearer token auth 32 + * - If no token, allows all authenticated requests 33 + * 34 + * @param {SingleUserConfig} singleUser 35 + * @returns {function} 36 + */ 37 + function singleUserMiddleware(singleUser) { 38 + const { userId, token } = singleUser; 39 + 40 + return async (c, next) => { 41 + // Health check is always public 42 + if (c.req.path === "/") { 43 + return next(); 44 + } 45 + 46 + // If token is configured, require it 47 + if (token) { 48 + const auth = c.req.header("Authorization"); 49 + if (!auth || auth !== `Bearer ${token}`) { 50 + return c.json({ error: "Unauthorized" }, 401); 51 + } 52 + } 53 + 54 + // Set the configured user ID 55 + c.set("userId", userId); 56 + return next(); 57 + }; 58 + } 59 + 60 + /** 61 + * Multi-user authentication middleware. 62 + * Authenticates via API key lookup in users module. 63 + * 64 + * @returns {function} 65 + */ 66 + function multiUserMiddleware() { 67 + return async (c, next) => { 68 + // Health check is public 69 + if (c.req.path === "/") { 70 + return next(); 71 + } 72 + 73 + const auth = c.req.header("Authorization"); 74 + if (!auth || !auth.startsWith("Bearer ")) { 75 + return c.json({ error: "Unauthorized" }, 401); 76 + } 77 + 78 + const apiKey = auth.slice(7); // Remove "Bearer " prefix 79 + const userId = users.getUserIdFromApiKey(apiKey); 80 + 81 + if (!userId) { 82 + return c.json({ error: "Unauthorized" }, 401); 83 + } 84 + 85 + c.set("userId", userId); 86 + return next(); 87 + }; 88 + } 89 + 90 + module.exports = { createAuthMiddleware };
+50
backend/server/config.js
··· 1 + /** 2 + * Server Configuration 3 + * 4 + * Loads configuration from environment variables. 5 + * Supports multi-user (default) and single-user modes. 6 + * 7 + * @typedef {Object} SingleUserConfig 8 + * @property {string} userId - User ID for single-user mode 9 + * @property {string} [token] - Optional bearer token for authentication 10 + * 11 + * @typedef {Object} ServerConfig 12 + * @property {'multi-user' | 'single-user'} mode - Server operation mode 13 + * @property {SingleUserConfig} [singleUser] - Single-user configuration 14 + */ 15 + 16 + /** 17 + * Load server configuration from environment. 18 + * 19 + * Environment variables: 20 + * - SINGLE_USER_MODE: Set to 'true' to enable single-user mode 21 + * - SINGLE_USER_ID: User ID for single-user mode (default: 'default') 22 + * - SINGLE_USER_TOKEN: Optional bearer token for authentication 23 + * 24 + * @returns {ServerConfig} 25 + */ 26 + function loadConfig() { 27 + if (process.env.SINGLE_USER_MODE === "true") { 28 + return { 29 + mode: "single-user", 30 + singleUser: { 31 + userId: process.env.SINGLE_USER_ID || "default", 32 + token: process.env.SINGLE_USER_TOKEN || undefined, 33 + }, 34 + }; 35 + } 36 + 37 + return { mode: "multi-user" }; 38 + } 39 + 40 + /** 41 + * Check if running in single-user mode. 42 + * 43 + * @param {ServerConfig} config 44 + * @returns {boolean} 45 + */ 46 + function isSingleUserMode(config) { 47 + return config.mode === "single-user"; 48 + } 49 + 50 + module.exports = { loadConfig, isSingleUserMode };
+24 -4
backend/server/db.js
··· 1 1 const { sqlFactory } = require("./sql"); 2 2 const { createStorageAdapter } = require("./storage"); 3 + const { loadConfig, isSingleUserMode } = require("./config"); 3 4 const path = require("path"); 4 5 const crypto = require("crypto"); 5 6 const fs = require("fs"); ··· 13 14 const REQUIRED_SYNC_COLUMNS = SCHEMA.validation.required_sync_columns; 14 15 15 16 const DATA_DIR = process.env.DATA_DIR || "./data"; 17 + 18 + // Load config once at startup 19 + const serverConfig = loadConfig(); 16 20 17 21 // Connection pool - one connection per user:profile 18 22 // Now stores SqlAdapter instances instead of raw Database instances ··· 22 26 const storageAdapters = new Map(); 23 27 24 28 /** 29 + * Get the profile directory path based on server mode. 30 + * - Multi-user: DATA_DIR/{userId}/profiles/{profileId} 31 + * - Single-user: DATA_DIR/profiles/{profileId} 32 + * 33 + * @param {string} userId 34 + * @param {string} profileId 35 + * @returns {string} 36 + */ 37 + function getProfileDir(userId, profileId) { 38 + if (isSingleUserMode(serverConfig)) { 39 + return path.join(DATA_DIR, "profiles", profileId); 40 + } 41 + return path.join(DATA_DIR, userId, "profiles", profileId); 42 + } 43 + 44 + /** 25 45 * Get storage adapter for a user's profile. 26 46 * @param {string} userId 27 47 * @param {string} [profileId='default'] ··· 33 53 return storageAdapters.get(key); 34 54 } 35 55 36 - const profileDir = path.join(DATA_DIR, userId, "profiles", profileId); 56 + const profileDir = getProfileDir(userId, profileId); 37 57 const imagesDir = path.join(profileDir, "images"); 38 58 39 59 const adapter = createStorageAdapter({ ··· 56 76 return connections.get(connectionKey); 57 77 } 58 78 59 - // Create user's profile directory 60 - const profileDir = path.join(DATA_DIR, userId, "profiles", profileId); 79 + // Create profile directory 80 + const profileDir = getProfileDir(userId, profileId); 61 81 if (!fs.existsSync(profileDir)) { 62 82 fs.mkdirSync(profileDir, { recursive: true }); 63 83 } ··· 799 819 const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB 800 820 801 821 function getUserImagesDir(userId, profileId = "default") { 802 - const profileDir = path.join(DATA_DIR, userId, "profiles", profileId); 822 + const profileDir = getProfileDir(userId, profileId); 803 823 return path.join(profileDir, "images"); 804 824 } 805 825
+26 -34
backend/server/index.js
··· 5 5 const db = require("./db"); 6 6 const users = require("./users"); 7 7 const backup = require("./backup"); 8 + const { loadConfig, isSingleUserMode } = require("./config"); 9 + const { createAuthMiddleware } = require("./auth"); 8 10 const { DATASTORE_VERSION, PROTOCOL_VERSION } = require("./version"); 11 + 12 + // Load configuration 13 + const config = loadConfig(); 9 14 10 15 const app = new Hono(); 11 16 ··· 16 21 c.header("X-Peek-Protocol-Version", String(PROTOCOL_VERSION)); 17 22 }); 18 23 19 - // Auth middleware - looks up user by API key 20 - app.use("*", async (c, next) => { 21 - // Health check is public 22 - if (c.req.path === "/") { 23 - return next(); 24 - } 25 - 26 - const auth = c.req.header("Authorization"); 27 - if (!auth || !auth.startsWith("Bearer ")) { 28 - return c.json({ error: "Unauthorized" }, 401); 29 - } 30 - 31 - const apiKey = auth.slice(7); // Remove "Bearer " prefix 32 - const userId = users.getUserIdFromApiKey(apiKey); 33 - 34 - if (!userId) { 35 - return c.json({ error: "Unauthorized" }, 401); 36 - } 37 - 38 - c.set("userId", userId); 39 - return next(); 40 - }); 24 + // Auth middleware - uses factory based on config 25 + app.use("*", createAuthMiddleware(config)); 41 26 42 27 // Version check middleware for sync endpoints 43 28 // Rejects requests with mismatched version headers (HTTP 409) ··· 747 732 } 748 733 } 749 734 750 - migrateFromLegacyApiKey(); 751 - migrateUserDataToProfiles(); 752 - users.migrateProfileFoldersToUuid(); 753 - deduplicateAllUsers(); 735 + // Multi-user mode: run migrations and user-based operations 736 + if (!isSingleUserMode(config)) { 737 + migrateFromLegacyApiKey(); 738 + migrateUserDataToProfiles(); 739 + users.migrateProfileFoldersToUuid(); 740 + deduplicateAllUsers(); 754 741 755 - // Force backup of all users on every deploy/restart (before serving requests) 756 - backup.createAllBackups().then(() => { 757 - console.log("Pre-deploy backup complete"); 758 - }).catch((err) => { 759 - console.error("Pre-deploy backup failed:", err); 760 - }); 742 + // Force backup of all users on every deploy/restart (before serving requests) 743 + backup.createAllBackups().then(() => { 744 + console.log("Pre-deploy backup complete"); 745 + }).catch((err) => { 746 + console.error("Pre-deploy backup failed:", err); 747 + }); 761 748 762 - // Set up hourly backup check (runs if >24h since last backup) 763 - setInterval(() => backup.checkAndRunDailyBackups(), 60 * 60 * 1000); 749 + // Set up hourly backup check (runs if >24h since last backup) 750 + setInterval(() => backup.checkAndRunDailyBackups(), 60 * 60 * 1000); 751 + } else { 752 + console.log("[config] Running in single-user mode"); 753 + console.log(`[config] User ID: ${config.singleUser.userId}`); 754 + console.log(`[config] Token auth: ${config.singleUser.token ? "enabled" : "disabled"}`); 755 + } 764 756 765 757 serve({ fetch: app.fetch, port }, (info) => { 766 758 console.log(`Server running on http://localhost:${info.port}`);