A Chrome extension to quickly capture URLs into Semble Collections at https://semble.so semble.so
at-proto semble chrome-extension

Add custom PDS server support with enhanced security and UX

This commit enables users on custom Personal Data Servers (PDS) to sign in, addressing the limitation mentioned in the README warning.

Key features:
- Added optional PDS Server field to login form
- Comprehensive URL validation with security checks (HTTPS required except localhost)
- Auto-normalization of PDS URLs (adds https:// if missing)
- Enhanced error handling with user-friendly messages
- Progress indicators during authentication (spinner in sign-in button)
- Client-side validation for handle format and PDS URL format
- Support for both bsky.social (default) and custom PDS servers

Technical changes:
- popup/popup.html: Added PDS server input field with helpful hints
- popup/popup.js: Added validation, normalization, and progress UI
- popup/styles.css: Added button spinner animation
- lib/atproto.js: Updated createSession to accept optional service parameter
- background/background.js: Updated authenticate to pass service to API
- README.md: Removed warning, documented custom PDS support

Security features:
- HTTPS enforcement for production servers
- HTTP allowed only for localhost/local networks
- Validation prevents malformed URLs
- Clear error messaging for network/auth failures

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+176 -21
+5 -3
README.md
··· 2 2 3 3 A Chrome extension for quickly capturing and organizing URLs into Semble Collections using your Bluesky account. 4 4 5 - > [!WARNING] 6 - > This extension currently only supports accounts hosted by **Bluesky** 7 - 8 5 ## About 9 6 10 7 Save interesting web pages directly into your Semble Collections. Simply click the extension icon, add an optional note, select a collection, and save—all without leaving your current page. ··· 13 10 14 11 - **One-Click Capture**: Save the current tab's URL with a single click 15 12 - **Bluesky Integration**: Secure authentication using Bluesky App Passwords 13 + - **Custom PDS Support**: Works with both Bluesky and custom PDS servers 16 14 - **Collection Management**: Choose from your existing Semble Collections 17 15 - **Add Notes**: Include optional notes with your captured URLs 18 16 - **Clean Interface**: Simple, intuitive design that stays out of your way ··· 45 43 2. Sign in with your Bluesky handle and App Password 46 44 - Don't have an App Password? Generate one at [Bluesky Settings](https://bsky.app/settings/app-passwords) 47 45 - Don't have a Bluesky account? [Sign up here](https://bsky.app) 46 + 3. (Optional) If you use a custom PDS server, enter your PDS server URL 47 + - For Bluesky accounts, leave this blank (defaults to `bsky.social`) 48 + - For custom PDS: enter the full URL (e.g., `https://my-pds.example.com`) 49 + - The extension supports both HTTP (localhost only) and HTTPS servers 48 50 49 51 ### Capturing URLs 50 52
+14 -3
background/background.js
··· 60 60 61 61 /** 62 62 * Authenticate with Semble using Bluesky credentials 63 + * @param {string} identifier - User handle 64 + * @param {string} password - App password 65 + * @param {string} [service] - Optional PDS service URL 63 66 */ 64 - async function authenticate(identifier, password) { 67 + async function authenticate(identifier, password, service) { 65 68 try { 66 - const sessionData = await createSession(identifier, password); 69 + console.log('Authenticating with:', { 70 + identifier, 71 + hasPassword: !!password, 72 + service: service || 'default (bsky.social)' 73 + }); 74 + 75 + const sessionData = await createSession(identifier, password, service); 67 76 await saveSession(sessionData); 77 + 78 + console.log('Authentication successful'); 68 79 return { success: true, session: sessionData }; 69 80 } catch (error) { 70 81 console.error('Authentication failed:', error); ··· 148 159 149 160 switch (request.action) { 150 161 case 'authenticate': 151 - authenticate(request.identifier, request.password) 162 + authenticate(request.identifier, request.password, request.service) 152 163 .then(sendResponse); 153 164 return true; // Keep channel open for async response 154 165
+29 -6
lib/atproto.js
··· 10 10 * Login with Bluesky app password to get Semble tokens 11 11 * @param {string} identifier - User handle (e.g., user.bsky.social) 12 12 * @param {string} password - Bluesky app password 13 + * @param {string} [service] - Optional PDS service URL (e.g., https://bsky.social) 13 14 * @returns {Promise<{accessToken: string, refreshToken: string}>} 14 15 */ 15 - async function createSession(identifier, password) { 16 + async function createSession(identifier, password, service) { 17 + const payload = { 18 + identifier, 19 + appPassword: password, 20 + }; 21 + 22 + // Include service parameter if provided (for custom PDS servers) 23 + if (service) { 24 + payload.service = service; 25 + } 26 + 16 27 const response = await fetch(`${SEMBLE_API_URL}/api/users/login/app-password`, { 17 28 method: 'POST', 18 29 headers: { 19 30 'Content-Type': 'application/json', 20 31 }, 21 - body: JSON.stringify({ 22 - identifier, 23 - appPassword: password, 24 - }), 32 + body: JSON.stringify(payload), 25 33 }); 26 34 27 35 if (!response.ok) { 28 36 const error = await response.json().catch(() => ({})); 29 - throw new Error(`Authentication failed: ${error.message || response.statusText}`); 37 + 38 + // Enhanced error handling 39 + let errorMessage = error.message || response.statusText; 40 + 41 + // Provide more specific error messages 42 + if (response.status === 401) { 43 + errorMessage = 'Invalid identifier or password'; 44 + } else if (response.status === 400) { 45 + errorMessage = error.message || 'Invalid request. Please check your inputs.'; 46 + } else if (response.status === 500) { 47 + errorMessage = 'Server error. Please try again later.'; 48 + } else if (response.status === 503) { 49 + errorMessage = 'Service temporarily unavailable. Please try again later.'; 50 + } 51 + 52 + throw new Error(`Authentication failed: ${errorMessage}`); 30 53 } 31 54 32 55 return await response.json();
+11 -1
popup/popup.html
··· 38 38 </p> 39 39 </div> 40 40 41 + <div class="form-group"> 42 + <label class="form-label">PDS Server (optional)</label> 43 + <input type="text" id="loginService" placeholder="https://bsky.social" class="input-filled" 44 + autocomplete="off"> 45 + <p class="form-hint"> 46 + Leave blank for default (bsky.social). Only change if you use a custom PDS server. 47 + </p> 48 + </div> 49 + 41 50 <button id="loginButton" type="button" class="btn btn-primary"> 42 - Sign in 51 + <span id="loginButtonText">Sign in</span> 52 + <span id="loginButtonSpinner" class="btn-spinner hidden"></span> 43 53 </button> 44 54 45 55 <div id="loginError" class="alert alert-error hidden"></div>
+106 -8
popup/popup.js
··· 5 5 6 6 // DOM elements 7 7 let loginScreen, captureScreen, loadingScreen; 8 - let loginHandle, loginPassword, loginButton, loginError; 8 + let loginHandle, loginPassword, loginService, loginButton, loginButtonText, loginButtonSpinner, loginError; 9 9 let currentUrl, noteInput, collectionSelect, submitButton, statusMessage, logoutButton; 10 10 11 11 // State ··· 23 23 loadingScreen = document.getElementById('loadingScreen'); 24 24 loginHandle = document.getElementById('loginHandle'); 25 25 loginPassword = document.getElementById('loginPassword'); 26 + loginService = document.getElementById('loginService'); 26 27 loginButton = document.getElementById('loginButton'); 28 + loginButtonText = document.getElementById('loginButtonText'); 29 + loginButtonSpinner = document.getElementById('loginButtonSpinner'); 27 30 loginError = document.getElementById('loginError'); 28 31 currentUrl = document.getElementById('currentUrl'); 29 32 noteInput = document.getElementById('noteInput'); ··· 178 181 } 179 182 180 183 /** 184 + * Validate URL format 185 + */ 186 + function isValidUrl(string) { 187 + try { 188 + const url = new URL(string); 189 + return url.protocol === 'http:' || url.protocol === 'https:'; 190 + } catch (_) { 191 + return false; 192 + } 193 + } 194 + 195 + /** 196 + * Validate and normalize PDS service URL 197 + */ 198 + function validateAndNormalizePDS(service) { 199 + if (!service) { 200 + return null; // Use default 201 + } 202 + 203 + const trimmed = service.trim(); 204 + 205 + // If it's just a domain without protocol, add https:// 206 + let normalized = trimmed; 207 + if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) { 208 + normalized = 'https://' + trimmed; 209 + } 210 + 211 + // Validate URL format 212 + if (!isValidUrl(normalized)) { 213 + throw new Error('Invalid PDS server URL format'); 214 + } 215 + 216 + const url = new URL(normalized); 217 + 218 + // Must be https (or http for localhost/127.0.0.1) 219 + if (url.protocol === 'http:') { 220 + const hostname = url.hostname.toLowerCase(); 221 + if (hostname !== 'localhost' && hostname !== '127.0.0.1' && !hostname.startsWith('192.168.')) { 222 + throw new Error('PDS server must use HTTPS (except for localhost)'); 223 + } 224 + } 225 + 226 + return normalized; 227 + } 228 + 229 + /** 181 230 * Handle login 182 231 */ 183 232 async function handleLogin() { 184 233 const handle = loginHandle.value.trim(); 185 234 const password = loginPassword.value.trim(); 235 + const service = loginService.value.trim(); 186 236 237 + // Validation 187 238 if (!handle || !password) { 188 239 showLoginError('Please enter both handle and password'); 189 240 return; 190 241 } 191 242 243 + // Validate handle format 244 + if (!handle.includes('.')) { 245 + showLoginError('Invalid handle format. Use format: user.domain.com'); 246 + return; 247 + } 248 + 249 + // Validate and normalize PDS service URL 250 + let normalizedService = null; 251 + try { 252 + normalizedService = validateAndNormalizePDS(service); 253 + } catch (error) { 254 + showLoginError(error.message); 255 + return; 256 + } 257 + 258 + // Show progress indicator 192 259 loginButton.disabled = true; 193 - loginButton.textContent = 'Signing in...'; 260 + loginButtonText.textContent = 'Signing in...'; 261 + loginButtonSpinner.classList.remove('hidden'); 194 262 hideLoginError(); 195 263 196 264 try { ··· 198 266 action: 'authenticate', 199 267 identifier: handle, 200 268 password: password, 269 + service: normalizedService, 201 270 }); 202 271 203 272 if (result.success) { 204 273 // Authentication successful 205 274 await loadCaptureScreen(); 206 275 } else { 207 - showLoginError(result.error || 'Authentication failed'); 208 - loginButton.disabled = false; 209 - loginButton.textContent = 'Sign In'; 276 + // Provide helpful error messages 277 + let errorMessage = result.error || 'Authentication failed'; 278 + 279 + // Enhanced error messaging 280 + if (errorMessage.includes('Invalid identifier or password')) { 281 + errorMessage = 'Invalid handle or app password. Please check your credentials.'; 282 + } else if (errorMessage.includes('Network')) { 283 + errorMessage = 'Network error. Please check your connection and try again.'; 284 + } else if (errorMessage.includes('timeout')) { 285 + errorMessage = 'Request timeout. The PDS server may be slow or unreachable.'; 286 + } else if (normalizedService && errorMessage.includes('fetch')) { 287 + errorMessage = 'Cannot reach PDS server. Please verify the server URL.'; 288 + } 289 + 290 + showLoginError(errorMessage); 291 + resetLoginButton(); 210 292 } 211 293 } catch (error) { 212 294 console.error('Login error:', error); 213 - showLoginError('An error occurred. Please try again.'); 214 - loginButton.disabled = false; 215 - loginButton.textContent = 'Sign In'; 295 + let errorMessage = 'An unexpected error occurred. Please try again.'; 296 + 297 + if (error.message && error.message.includes('Network')) { 298 + errorMessage = 'Network error. Please check your internet connection.'; 299 + } 300 + 301 + showLoginError(errorMessage); 302 + resetLoginButton(); 216 303 } 217 304 } 218 305 219 306 /** 307 + * Reset login button to initial state 308 + */ 309 + function resetLoginButton() { 310 + loginButton.disabled = false; 311 + loginButtonText.textContent = 'Sign in'; 312 + loginButtonSpinner.classList.add('hidden'); 313 + } 314 + 315 + /** 220 316 * Handle logout 221 317 */ 222 318 async function handleLogout() { ··· 225 321 showScreen('login'); 226 322 loginHandle.value = ''; 227 323 loginPassword.value = ''; 324 + loginService.value = ''; 325 + resetLoginButton(); 228 326 } catch (error) { 229 327 console.error('Logout error:', error); 230 328 }
+11
popup/styles.css
··· 386 386 } 387 387 } 388 388 389 + /* ========== Button Spinner ========== */ 390 + .btn-spinner { 391 + display: inline-block; 392 + width: 16px; 393 + height: 16px; 394 + border: 2px solid rgba(255, 255, 255, 0.3); 395 + border-top-color: white; 396 + border-radius: 50%; 397 + animation: spin 0.6s linear infinite; 398 + } 399 + 389 400 /* ========== Utility Classes ========== */ 390 401 .hidden { 391 402 display: none !important;