Auto-indexing service and GraphQL API for AT Protocol Records
0
fork

Configure Feed

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

add auto oauth client registration with configured aip url

+820 -137
+70 -9
docs/deployment.md
··· 11 11 | `PORT` | No | `8000` | Server port | 12 12 | `SECRET_KEY_BASE` | Recommended | Auto-generated | Session encryption key (64+ chars). **Must persist across restarts** | 13 13 | `ADMIN_DIDS` | Optional | - | Comma-separated DIDs for admin access (e.g., `did:plc:abc,did:plc:xyz`) | 14 - | `OAUTH_CLIENT_ID` | Optional | - | OAuth client ID | 15 - | `OAUTH_CLIENT_SECRET` | Optional | - | OAuth client secret | 14 + | `ENABLE_OAUTH_AUTO_REGISTER` | Optional | `false` | Enable automatic OAuth client registration with AIP on server boot | 15 + | `OAUTH_CLIENT_ID` | Optional | - | OAuth client ID (auto-registered if `ENABLE_OAUTH_AUTO_REGISTER=true`) | 16 + | `OAUTH_CLIENT_SECRET` | Optional | - | OAuth client secret (auto-registered if `ENABLE_OAUTH_AUTO_REGISTER=true`) | 16 17 | `OAUTH_REDIRECT_URI` | Optional | `http://localhost:8000/oauth/callback` | OAuth callback URL | 17 18 | `AIP_BASE_URL` | Optional | `https://auth.example.com` | AT Protocol Identity Provider URL | 18 19 | `JETSTREAM_URL` | No | `wss://jetstream2.us-west.bsky.network/subscribe` | Jetstream WebSocket endpoint | ··· 24 25 - **DATABASE_URL**: Must point to a persistent volume location 25 26 - **SECRET_KEY_BASE**: Generate with `openssl rand -base64 48`. Store as a secret and keep persistent 26 27 - **HOST**: Set to `0.0.0.0` in container environments 27 - - **ADMIN_DIDS**: Required for backfill 28 + - **ADMIN_DIDS**: Required for backfill and settings page access 29 + 30 + ### OAuth Configuration 31 + 32 + Quickslice supports two approaches for OAuth configuration: 33 + 34 + #### Option A: Auto-Registration (Recommended) 35 + 36 + Set `ENABLE_OAUTH_AUTO_REGISTER=true` to automatically register an OAuth client with your AIP server on startup. The server will: 37 + 38 + 1. Check if OAuth credentials exist in the database 39 + 2. If not found, automatically register with the AIP server at `/oauth/clients/register` 40 + 3. Store the client ID and secret in the database for future use 41 + 4. Retry with exponential backoff (2, 4, 8... up to 30 minutes) if registration fails 42 + 43 + **Benefits:** 44 + - No manual OAuth client setup required 45 + - Credentials persist in the database across restarts 46 + - Automatic retry if AIP server is temporarily unavailable 47 + 48 + **Requirements:** 49 + - `AIP_BASE_URL` must be set to your AIP server URL 50 + - AIP server must support dynamic client registration (RFC 7591) 51 + 52 + #### Option B: Manual Configuration 53 + 54 + Alternatively, you can manually register an OAuth client with your AIP server and provide the credentials via environment variables: 55 + 56 + - `OAUTH_CLIENT_ID`: Your pre-registered client ID 57 + - `OAUTH_CLIENT_SECRET`: Your pre-registered client secret 58 + 59 + **Note:** Manual credentials take precedence over auto-registered credentials. 28 60 29 61 ## SQLite Volume Setup 30 62 ··· 81 113 ```bash 82 114 fly secrets set SECRET_KEY_BASE=$(openssl rand -base64 48) 83 115 84 - # Optional: OAuth configuration 116 + # Optional: Admin access 117 + fly secrets set ADMIN_DIDS=did:plc:your_did 118 + 119 + # OAuth configuration (choose one approach): 120 + 121 + # Option A: Auto-registration (recommended) 122 + # Automatically registers OAuth client with AIP on startup 123 + fly secrets set ENABLE_OAUTH_AUTO_REGISTER=true 124 + fly secrets set AIP_BASE_URL=https://your-aip-server.com 125 + 126 + # Option B: Manual configuration 127 + # Use pre-existing OAuth client credentials 85 128 fly secrets set OAUTH_CLIENT_ID=your_client_id 86 129 fly secrets set OAUTH_CLIENT_SECRET=your_client_secret 87 - 88 - # Optional: Admin access 89 - fly secrets set ADMIN_DIDS=did:plc:your_did 90 130 ``` 91 131 92 132 ### 4. Deploy ··· 122 162 Optional variables: 123 163 ``` 124 164 ADMIN_DIDS=did:plc:your_did 125 - OAUTH_CLIENT_ID=your_client_id 126 - OAUTH_CLIENT_SECRET=your_client_secret 165 + 166 + # OAuth - Option A: Auto-registration (recommended) 167 + ENABLE_OAUTH_AUTO_REGISTER=true 168 + AIP_BASE_URL=https://your-aip-server.com 127 169 OAUTH_REDIRECT_URI=https://your-app.up.railway.app/oauth/callback 170 + 171 + # OAuth - Option B: Manual configuration 172 + # OAUTH_CLIENT_ID=your_client_id 173 + # OAUTH_CLIENT_SECRET=your_client_secret 174 + # OAUTH_REDIRECT_URI=https://your-app.up.railway.app/oauth/callback 128 175 ``` 129 176 130 177 ### 3. Add a volume ··· 185 232 - DATABASE_URL=/data/quickslice.db 186 233 - SECRET_KEY_BASE=${SECRET_KEY_BASE} 187 234 - ADMIN_DIDS=${ADMIN_DIDS} 235 + # OAuth auto-registration (recommended) 236 + - ENABLE_OAUTH_AUTO_REGISTER=${ENABLE_OAUTH_AUTO_REGISTER:-false} 237 + - AIP_BASE_URL=${AIP_BASE_URL} 238 + # Or use manual OAuth configuration 239 + # - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} 240 + # - OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET} 188 241 restart: unless-stopped 189 242 healthcheck: 190 243 test: ["CMD", "wget", "--spider", "-q", "http://localhost:8000/health"] ··· 201 254 ```bash 202 255 SECRET_KEY_BASE=<generate-with-openssl-rand> 203 256 ADMIN_DIDS=did:plc:your_did 257 + 258 + # OAuth auto-registration (recommended) 259 + ENABLE_OAUTH_AUTO_REGISTER=true 260 + AIP_BASE_URL=https://your-aip-server.com 261 + 262 + # Or use manual OAuth configuration 263 + # OAUTH_CLIENT_ID=your_client_id 264 + # OAUTH_CLIENT_SECRET=your_client_secret 204 265 ``` 205 266 206 267 Start the service:
+12 -4
server/.env.example
··· 6 6 # IMPORTANT: This MUST be persistent across server restarts or sessions will be invalidated 7 7 SECRET_KEY_BASE=CHANGE_ME_TO_A_RANDOM_64_CHARACTER_STRING 8 8 9 - # OAuth Client Credentials (obtained from your OAuth provider) 10 - OAUTH_CLIENT_ID=your_client_id 11 - OAUTH_CLIENT_SECRET=your_client_secret 9 + # OAuth Auto-Registration (recommended) 10 + # When enabled, the server will automatically register as an OAuth client with your AIP server 11 + # on startup if no credentials exist. Credentials are stored in the database config table. 12 + ENABLE_OAUTH_AUTO_REGISTER=true 13 + 14 + # OAuth Client Credentials (manual configuration - optional) 15 + # If you set these environment variables, they will override auto-registered credentials. 16 + # You can leave these commented out if using auto-registration. 17 + # OAUTH_CLIENT_ID=your_client_id 18 + # OAUTH_CLIENT_SECRET=your_client_secret 12 19 13 20 # OAuth Redirect URI (must match the URI registered with your OAuth provider) 14 - OAUTH_REDIRECT_URI=http://localhost:8000/oauth/callback 21 + # Defaults to {EXTERNAL_BASE}/oauth/callback if not set 22 + # OAUTH_REDIRECT_URI=http://localhost:8000/oauth/callback 15 23 16 24 # Admin DIDs (comma-separated list of DIDs that have admin access to backfill, GraphiQL, etc.) 17 25 ADMIN_DIDS=did:plc:example1,did:plc:example2
+44
server/src/database.gleam
··· 371 371 Ok(Nil) 372 372 } 373 373 374 + /// Get OAuth client credentials from config table 375 + /// Returns a tuple of (client_id, client_secret, redirect_uri) if all values exist 376 + pub fn get_oauth_credentials( 377 + conn: sqlight.Connection, 378 + ) -> Result(Option(#(String, String, String)), sqlight.Error) { 379 + case get_config(conn, "oauth_client_id"), get_config(conn, "oauth_client_secret") { 380 + Ok(client_id), Ok(client_secret) -> { 381 + let redirect_uri = case get_config(conn, "oauth_redirect_uri") { 382 + Ok(uri) -> uri 383 + Error(_) -> "" 384 + } 385 + Ok(Some(#(client_id, client_secret, redirect_uri))) 386 + } 387 + Error(_), _ -> Ok(None) 388 + _, Error(_) -> Ok(None) 389 + } 390 + } 391 + 392 + /// Delete OAuth credentials from config table 393 + pub fn delete_oauth_credentials( 394 + conn: sqlight.Connection, 395 + ) -> Result(Nil, sqlight.Error) { 396 + use _ <- result.try(delete_config(conn, "oauth_client_id")) 397 + use _ <- result.try(delete_config(conn, "oauth_client_secret")) 398 + use _ <- result.try(delete_config(conn, "oauth_redirect_uri")) 399 + Ok(Nil) 400 + } 401 + 402 + /// Generic config deletion function 403 + fn delete_config( 404 + conn: sqlight.Connection, 405 + key: String, 406 + ) -> Result(Nil, sqlight.Error) { 407 + let sql = "DELETE FROM config WHERE key = ?" 408 + 409 + use _ <- result.try(sqlight.query( 410 + sql, 411 + on: conn, 412 + with: [sqlight.text(key)], 413 + expecting: decode.string, 414 + )) 415 + Ok(Nil) 416 + } 417 + 374 418 /// Deletes all lexicons from the database 375 419 pub fn delete_all_lexicons( 376 420 conn: sqlight.Connection,
+413
server/src/oauth/registration.gleam
··· 1 + import database 2 + import gleam/dynamic/decode 3 + import gleam/erlang/process 4 + import gleam/http.{Post} 5 + import gleam/http/request 6 + import gleam/httpc 7 + import gleam/int 8 + import gleam/json 9 + import gleam/option 10 + import gleam/otp/actor 11 + import gleam/result 12 + import gleam/string 13 + import logging 14 + import oauth/handlers 15 + import sqlight 16 + 17 + pub type RegistrationStatus { 18 + NotRegistered 19 + Registered(client_id: String) 20 + RegistrationFailed(error: String) 21 + } 22 + 23 + type ClientRegistrationRequest { 24 + ClientRegistrationRequest( 25 + client_name: String, 26 + redirect_uris: List(String), 27 + grant_types: List(String), 28 + response_types: List(String), 29 + token_endpoint_auth_method: String, 30 + scope: String, 31 + ) 32 + } 33 + 34 + type ClientRegistrationResponse { 35 + ClientRegistrationResponse(client_id: String, client_secret: String) 36 + } 37 + 38 + /// Check registration status by looking at database 39 + pub fn check_registration_status( 40 + db: sqlight.Connection, 41 + ) -> RegistrationStatus { 42 + case database.get_oauth_credentials(db) { 43 + Ok(option.Some(#(client_id, _client_secret, _redirect_uri))) -> 44 + Registered(client_id) 45 + Ok(option.None) -> NotRegistered 46 + Error(_) -> NotRegistered 47 + } 48 + } 49 + 50 + /// Ensure OAuth client credentials exist, registering if needed 51 + /// Returns OAuthConfig or Error 52 + pub fn ensure_oauth_client( 53 + db: sqlight.Connection, 54 + aip_base_url: String, 55 + redirect_uri: String, 56 + client_name: String, 57 + ) -> Result(handlers.OAuthConfig, String) { 58 + // Check if credentials exist in database 59 + case database.get_oauth_credentials(db) { 60 + Ok(option.Some(#(client_id, client_secret, _stored_uri))) -> { 61 + logging.log(logging.Info, "[oauth] Using stored OAuth credentials") 62 + Ok(handlers.OAuthConfig( 63 + client_id: client_id, 64 + client_secret: client_secret, 65 + redirect_uri: redirect_uri, 66 + auth_url: aip_base_url, 67 + )) 68 + } 69 + Ok(option.None) | Error(_) -> { 70 + // No credentials found, register new client 71 + logging.log( 72 + logging.Info, 73 + "[oauth] No OAuth credentials found, registering new client...", 74 + ) 75 + 76 + case register_new_client(aip_base_url, redirect_uri, client_name) { 77 + Ok(#(client_id, client_secret)) -> { 78 + logging.log( 79 + logging.Info, 80 + "[oauth] OAuth client registered: " <> client_id, 81 + ) 82 + 83 + // Store credentials in database 84 + case store_oauth_credentials(db, client_id, client_secret, redirect_uri) { 85 + Ok(_) -> { 86 + logging.log( 87 + logging.Info, 88 + "[oauth] OAuth credentials stored in database", 89 + ) 90 + Ok(handlers.OAuthConfig( 91 + client_id: client_id, 92 + client_secret: client_secret, 93 + redirect_uri: redirect_uri, 94 + auth_url: aip_base_url, 95 + )) 96 + } 97 + Error(err) -> { 98 + logging.log( 99 + logging.Error, 100 + "[oauth] Failed to store credentials: " <> string.inspect(err), 101 + ) 102 + // Return credentials anyway, but warn 103 + Ok(handlers.OAuthConfig( 104 + client_id: client_id, 105 + client_secret: client_secret, 106 + redirect_uri: redirect_uri, 107 + auth_url: aip_base_url, 108 + )) 109 + } 110 + } 111 + } 112 + Error(err) -> { 113 + logging.log( 114 + logging.Error, 115 + "[oauth] Client registration failed: " <> err, 116 + ) 117 + Error("Client registration failed: " <> err) 118 + } 119 + } 120 + } 121 + } 122 + } 123 + 124 + /// Register a new OAuth client with the AIP server 125 + /// Returns (client_id, client_secret) or Error 126 + pub fn register_new_client( 127 + aip_base_url: String, 128 + redirect_uri: String, 129 + client_name: String, 130 + ) -> Result(#(String, String), String) { 131 + let registration_url = aip_base_url <> "/oauth/clients/register" 132 + 133 + // Build registration request body (RFC 7591) 134 + let registration_request = 135 + ClientRegistrationRequest( 136 + client_name: client_name, 137 + redirect_uris: [redirect_uri], 138 + grant_types: ["authorization_code", "refresh_token"], 139 + response_types: ["code"], 140 + token_endpoint_auth_method: "client_secret_basic", 141 + scope: "profile openid atproto transition:generic", 142 + ) 143 + 144 + let body = encode_registration_request(registration_request) 145 + let body_string = json.to_string(body) 146 + 147 + // Make HTTP request 148 + case request.to(registration_url) { 149 + Ok(req) -> { 150 + let req = 151 + req 152 + |> request.set_method(Post) 153 + |> request.set_header("content-type", "application/json") 154 + |> request.set_body(body_string) 155 + 156 + case httpc.send(req) { 157 + Ok(resp) -> { 158 + case resp.status { 159 + 200 | 201 -> { 160 + // Parse response (both 200 OK and 201 Created are valid) 161 + case json.parse(resp.body, decode.dynamic) { 162 + Ok(parsed) -> { 163 + case decode_registration_response(parsed) { 164 + Ok(response) -> Ok(#(response.client_id, response.client_secret)) 165 + Error(err) -> Error(err) 166 + } 167 + } 168 + Error(_) -> Error("Failed to parse registration response JSON") 169 + } 170 + } 171 + _ -> 172 + Error( 173 + "Registration failed with status " 174 + <> int.to_string(resp.status) 175 + <> ": " 176 + <> resp.body, 177 + ) 178 + } 179 + } 180 + Error(_) -> Error("Failed to send registration request to AIP") 181 + } 182 + } 183 + Error(_) -> Error("Invalid registration URL: " <> registration_url) 184 + } 185 + } 186 + 187 + /// Store OAuth credentials in the database config table 188 + pub fn store_oauth_credentials( 189 + db: sqlight.Connection, 190 + client_id: String, 191 + client_secret: String, 192 + redirect_uri: String, 193 + ) -> Result(Nil, sqlight.Error) { 194 + use _ <- result.try(database.set_config(db, "oauth_client_id", client_id)) 195 + use _ <- result.try(database.set_config(db, "oauth_client_secret", client_secret)) 196 + use _ <- result.try(database.set_config(db, "oauth_redirect_uri", redirect_uri)) 197 + Ok(Nil) 198 + } 199 + 200 + // Helper Functions --------------------------------------------------------------- 201 + 202 + fn encode_registration_request( 203 + req: ClientRegistrationRequest, 204 + ) -> json.Json { 205 + json.object([ 206 + #("client_name", json.string(req.client_name)), 207 + #( 208 + "redirect_uris", 209 + json.array(req.redirect_uris, fn(uri) { json.string(uri) }), 210 + ), 211 + #( 212 + "grant_types", 213 + json.array(req.grant_types, fn(grant) { json.string(grant) }), 214 + ), 215 + #( 216 + "response_types", 217 + json.array(req.response_types, fn(response) { json.string(response) }), 218 + ), 219 + #( 220 + "token_endpoint_auth_method", 221 + json.string(req.token_endpoint_auth_method), 222 + ), 223 + #("scope", json.string(req.scope)), 224 + ]) 225 + } 226 + 227 + fn decode_registration_response( 228 + parsed: decode.Dynamic, 229 + ) -> Result(ClientRegistrationResponse, String) { 230 + let client_id = 231 + decode.run(parsed, decode.at(["client_id"], decode.string)) 232 + |> result.map_error(fn(_) { "Missing client_id in response" }) 233 + 234 + let client_secret = 235 + decode.run(parsed, decode.at(["client_secret"], decode.string)) 236 + |> result.map_error(fn(_) { "Missing client_secret in response" }) 237 + 238 + case client_id, client_secret { 239 + Ok(id), Ok(secret) -> 240 + Ok(ClientRegistrationResponse(client_id: id, client_secret: secret)) 241 + Error(err), _ -> Error(err) 242 + _, Error(err) -> Error(err) 243 + } 244 + } 245 + 246 + // Retry Logic ------------------------------------------------------------------- 247 + 248 + pub type RetryState { 249 + RetryState( 250 + db: sqlight.Connection, 251 + aip_base_url: String, 252 + redirect_uri: String, 253 + client_name: String, 254 + attempt: Int, 255 + max_backoff_minutes: Int, 256 + ) 257 + } 258 + 259 + pub type RetryMessage { 260 + AttemptRegistration(actor_subject: process.Subject(RetryMessage)) 261 + Stop 262 + } 263 + 264 + /// Start a retry actor that attempts registration with exponential backoff 265 + /// Returns a Subject that can be used to send messages to the actor 266 + pub fn start_retry_actor( 267 + db: sqlight.Connection, 268 + aip_base_url: String, 269 + redirect_uri: String, 270 + client_name: String, 271 + ) -> Result(process.Subject(RetryMessage), actor.StartError) { 272 + let initial_state = 273 + RetryState( 274 + db: db, 275 + aip_base_url: aip_base_url, 276 + redirect_uri: redirect_uri, 277 + client_name: client_name, 278 + attempt: 0, 279 + max_backoff_minutes: 30, 280 + ) 281 + 282 + let result = 283 + actor.new(initial_state) 284 + |> actor.on_message(handle_retry_message) 285 + |> actor.start 286 + 287 + case result { 288 + Ok(started) -> Ok(started.data) 289 + Error(err) -> Error(err) 290 + } 291 + } 292 + 293 + fn handle_retry_message( 294 + state: RetryState, 295 + message: RetryMessage, 296 + ) -> actor.Next(RetryState, RetryMessage) { 297 + case message { 298 + Stop -> { 299 + logging.log(logging.Info, "[oauth] Retry actor stopped") 300 + actor.stop() 301 + } 302 + AttemptRegistration(actor_subject) -> { 303 + // Check if already registered 304 + case check_registration_status(state.db) { 305 + Registered(client_id) -> { 306 + logging.log( 307 + logging.Info, 308 + "[oauth] Registration successful! Client ID: " <> client_id, 309 + ) 310 + actor.stop() 311 + } 312 + NotRegistered | RegistrationFailed(_) -> { 313 + // Attempt registration 314 + logging.log( 315 + logging.Info, 316 + "[oauth] Retry attempt " 317 + <> int.to_string(state.attempt + 1) 318 + <> " - attempting registration...", 319 + ) 320 + 321 + case 322 + register_new_client( 323 + state.aip_base_url, 324 + state.redirect_uri, 325 + state.client_name, 326 + ) 327 + { 328 + Ok(#(client_id, client_secret)) -> { 329 + logging.log( 330 + logging.Info, 331 + "[oauth] Registration successful: " <> client_id, 332 + ) 333 + 334 + // Store credentials 335 + case 336 + store_oauth_credentials( 337 + state.db, 338 + client_id, 339 + client_secret, 340 + state.redirect_uri, 341 + ) 342 + { 343 + Ok(_) -> { 344 + logging.log( 345 + logging.Info, 346 + "[oauth] Credentials stored successfully", 347 + ) 348 + actor.stop() 349 + } 350 + Error(err) -> { 351 + logging.log( 352 + logging.Error, 353 + "[oauth] Failed to store credentials: " 354 + <> string.inspect(err), 355 + ) 356 + // Schedule next retry 357 + schedule_next_retry(state, actor_subject) 358 + } 359 + } 360 + } 361 + Error(err) -> { 362 + logging.log( 363 + logging.Error, 364 + "[oauth] Registration attempt failed: " <> err, 365 + ) 366 + // Schedule next retry 367 + schedule_next_retry(state, actor_subject) 368 + } 369 + } 370 + } 371 + } 372 + } 373 + } 374 + } 375 + 376 + fn schedule_next_retry( 377 + state: RetryState, 378 + actor_subject: process.Subject(RetryMessage), 379 + ) -> actor.Next(RetryState, RetryMessage) { 380 + let new_attempt = state.attempt + 1 381 + 382 + // Calculate backoff: 2^attempt minutes, capped at max_backoff_minutes 383 + let backoff_minutes = 384 + int.min(int.bitwise_shift_left(1, new_attempt), state.max_backoff_minutes) 385 + let backoff_ms = backoff_minutes * 60 * 1000 386 + 387 + logging.log( 388 + logging.Info, 389 + "[oauth] Scheduling next retry in " 390 + <> int.to_string(backoff_minutes) 391 + <> " minutes", 392 + ) 393 + 394 + // Spawn a timer process that will send a message after the delay 395 + let _ = 396 + process.spawn_unlinked(fn() { 397 + process.sleep(backoff_ms) 398 + process.send(actor_subject, AttemptRegistration(actor_subject)) 399 + }) 400 + 401 + // Continue with updated attempt count 402 + actor.continue(RetryState(..state, attempt: new_attempt)) 403 + } 404 + 405 + /// Trigger an immediate retry attempt 406 + pub fn trigger_retry(subject: process.Subject(RetryMessage)) -> Nil { 407 + process.send(subject, AttemptRegistration(subject)) 408 + } 409 + 410 + /// Stop the retry actor 411 + pub fn stop_retry(subject: process.Subject(RetryMessage)) -> Nil { 412 + process.send(subject, Stop) 413 + }
+112 -55
server/src/pages/settings.gleam
··· 12 12 pub fn view( 13 13 db: sqlight.Connection, 14 14 current_user: Option(#(String, String)), 15 - is_admin: Bool, 16 15 flash_kind: Option(String), 17 16 flash_message: Option(String), 18 17 ) -> Element(msg) { 19 18 let data = fetch_settings(db) 20 - render(data, current_user, is_admin, flash_kind, flash_message) 19 + render(data, current_user, flash_kind, flash_message) 21 20 } 22 21 23 22 /// Settings data 24 23 pub type SettingsData { 25 - SettingsData(domain_authority: String) 24 + SettingsData( 25 + domain_authority: String, 26 + oauth_client_id: Option(String), 27 + ) 26 28 } 27 29 28 30 /// Fetch current settings ··· 32 34 Error(_) -> "" 33 35 } 34 36 35 - SettingsData(domain_authority: domain_authority) 37 + let oauth_client_id = case database.get_oauth_credentials(db) { 38 + Ok(option.Some(#(client_id, _secret, _uri))) -> option.Some(client_id) 39 + _ -> option.None 40 + } 41 + 42 + SettingsData(domain_authority: domain_authority, oauth_client_id: oauth_client_id) 36 43 } 37 44 38 45 /// Render the complete settings page 39 46 fn render( 40 47 data: SettingsData, 41 48 current_user: Option(#(String, String)), 42 - is_admin: Bool, 43 49 flash_kind: Option(String), 44 50 flash_message: Option(String), 45 51 ) -> Element(msg) { ··· 50 56 element.text("Settings"), 51 57 ]), 52 58 alert.maybe_alert(flash_kind, flash_message), 53 - render_settings_form(data, is_admin), 59 + render_settings_form(data), 54 60 ], 55 61 current_user: current_user, 56 62 domain_authority: option.None, ··· 58 64 } 59 65 60 66 /// Render the settings form 61 - fn render_settings_form(data: SettingsData, is_admin: Bool) -> Element(msg) { 67 + fn render_settings_form(data: SettingsData) -> Element(msg) { 62 68 html.div([attribute.class("max-w-2xl space-y-6")], [ 63 69 // Domain Authority Section 64 70 html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ ··· 134 140 ], 135 141 ), 136 142 ]), 137 - // Danger Zone Section (admin only) 138 - case is_admin { 139 - True -> 140 - html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 141 - html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 142 - element.text("Danger Zone"), 143 - ]), 144 - html.p([attribute.class("text-sm text-zinc-400 mb-4")], [ 145 - element.text("This will clear all indexed data:"), 146 - ]), 147 - html.ul([attribute.class("text-sm text-zinc-400 mb-4 ml-4 list-disc")], [ 148 - html.li([], [element.text("Domain authority configuration")]), 149 - html.li([], [element.text("All lexicon definitions")]), 150 - html.li([], [element.text("All indexed records")]), 151 - html.li([], [element.text("All actors")]), 152 - ]), 153 - html.p([attribute.class("text-sm text-zinc-400 mb-4")], [ 154 - element.text("Records can be re-indexed via backfill."), 155 - ]), 156 - html.form( 157 - [ 158 - attribute.method("post"), 159 - attribute.action("/settings"), 160 - ], 161 - [ 162 - html.input([ 163 - attribute.type_("hidden"), 164 - attribute.name("action"), 165 - attribute.value("reset"), 143 + // OAuth Registration Section 144 + html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 145 + html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 146 + element.text("OAuth Configuration"), 147 + ]), 148 + case data.oauth_client_id { 149 + option.Some(client_id) -> { 150 + html.div([attribute.class("space-y-3")], [ 151 + html.div([attribute.class("flex items-center gap-2")], [ 152 + html.div([ 153 + attribute.class( 154 + "w-2 h-2 bg-green-500 rounded-full", 155 + ), 156 + ], []), 157 + html.p([attribute.class("text-sm text-zinc-300")], [ 158 + element.text("OAuth client registered"), 166 159 ]), 167 - input.form_text_input( 168 - label: "Type RESET to confirm", 169 - name: "confirm", 170 - value: "", 171 - placeholder: "RESET", 172 - required: True, 160 + ]), 161 + html.div([attribute.class("bg-zinc-900/50 rounded p-3")], [ 162 + html.p([attribute.class("text-xs text-zinc-500 mb-1")], [ 163 + element.text("Client ID:"), 164 + ]), 165 + html.p([attribute.class("text-sm text-zinc-300 font-mono")], [ 166 + element.text(client_id), 167 + ]), 168 + ]), 169 + html.p([attribute.class("text-sm text-zinc-500")], [ 170 + element.text( 171 + "OAuth client credentials are stored in the database. Use \"Reset Everything\" to clear and trigger re-registration.", 173 172 ), 174 - html.div([attribute.class("flex gap-3")], [ 175 - html.button( 176 - [ 177 - attribute.type_("submit"), 178 - attribute.class( 179 - "font-mono px-4 py-2 text-sm text-red-400 border border-red-900 hover:bg-red-900/30 rounded transition-colors cursor-pointer", 180 - ), 181 - ], 182 - [element.text("Reset Everything")], 173 + ]), 174 + ]) 175 + } 176 + option.None -> { 177 + html.div([attribute.class("space-y-3")], [ 178 + html.div([attribute.class("flex items-center gap-2")], [ 179 + html.div([ 180 + attribute.class( 181 + "w-2 h-2 bg-zinc-500 rounded-full", 183 182 ), 183 + ], []), 184 + html.p([attribute.class("text-sm text-zinc-400")], [ 185 + element.text("OAuth client not registered"), 184 186 ]), 185 - ], 187 + ]), 188 + html.p([attribute.class("text-sm text-zinc-500")], [ 189 + element.text( 190 + "Set ENABLE_OAUTH_AUTO_REGISTER=true in your .env file to enable automatic OAuth client registration. The server will automatically register with your configured AIP server on startup.", 191 + ), 192 + ]), 193 + ]) 194 + } 195 + }, 196 + ]), 197 + // Danger Zone Section 198 + html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 199 + html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 200 + element.text("Danger Zone"), 201 + ]), 202 + html.p([attribute.class("text-sm text-zinc-400 mb-4")], [ 203 + element.text("This will clear all indexed data:"), 204 + ]), 205 + html.ul([attribute.class("text-sm text-zinc-400 mb-4 ml-4 list-disc")], [ 206 + html.li([], [element.text("Domain authority configuration")]), 207 + html.li([], [element.text("OAuth client credentials")]), 208 + html.li([], [element.text("All lexicon definitions")]), 209 + html.li([], [element.text("All indexed records")]), 210 + html.li([], [element.text("All actors")]), 211 + ]), 212 + html.p([attribute.class("text-sm text-zinc-400 mb-4")], [ 213 + element.text("Records can be re-indexed via backfill."), 214 + ]), 215 + html.form( 216 + [ 217 + attribute.method("post"), 218 + attribute.action("/settings"), 219 + ], 220 + [ 221 + html.input([ 222 + attribute.type_("hidden"), 223 + attribute.name("action"), 224 + attribute.value("reset"), 225 + ]), 226 + input.form_text_input( 227 + label: "Type RESET to confirm", 228 + name: "confirm", 229 + value: "", 230 + placeholder: "RESET", 231 + required: True, 186 232 ), 187 - ]) 188 - False -> element.none() 189 - }, 233 + html.div([attribute.class("flex gap-3")], [ 234 + html.button( 235 + [ 236 + attribute.type_("submit"), 237 + attribute.class( 238 + "font-mono px-4 py-2 text-sm text-red-400 border border-red-900 hover:bg-red-900/30 rounded transition-colors cursor-pointer", 239 + ), 240 + ], 241 + [element.text("Reset Everything")], 242 + ), 243 + ]), 244 + ], 245 + ), 246 + ]), 190 247 // Account Section 191 248 html.div([attribute.class("bg-zinc-800/50 rounded p-6")], [ 192 249 html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [
+106 -16
server/src/server.gleam
··· 22 22 import lustre_handlers 23 23 import mist 24 24 import oauth/handlers 25 + import oauth/registration 25 26 import oauth/session 26 27 import pages/index 27 28 import pubsub ··· 283 284 Error(_) -> "https://auth.example.com" 284 285 } 285 286 286 - // OAuth configuration 287 - let oauth_client_id = case envoy.get("OAUTH_CLIENT_ID") { 288 - Ok(id) -> id 289 - Error(_) -> "" 290 - } 291 - 292 - let oauth_client_secret = case envoy.get("OAUTH_CLIENT_SECRET") { 293 - Ok(secret) -> secret 294 - Error(_) -> "" 287 + // OAuth configuration - check environment variables first for backwards compatibility 288 + let enable_auto_register = case envoy.get("ENABLE_OAUTH_AUTO_REGISTER") { 289 + Ok(val) -> val == "true" || val == "1" 290 + Error(_) -> False 295 291 } 296 292 293 + // Determine redirect URI from environment or use default 297 294 let oauth_redirect_uri = case envoy.get("OAUTH_REDIRECT_URI") { 298 295 Ok(uri) -> uri 299 296 Error(_) -> "http://localhost:8000/oauth/callback" 300 297 } 301 298 302 - let oauth_config = 303 - handlers.OAuthConfig( 304 - client_id: oauth_client_id, 305 - client_secret: oauth_client_secret, 306 - redirect_uri: oauth_redirect_uri, 307 - auth_url: auth_base_url, 308 - ) 299 + let oauth_config = case 300 + envoy.get("OAUTH_CLIENT_ID"), 301 + envoy.get("OAUTH_CLIENT_SECRET") 302 + { 303 + Ok(id), Ok(secret) if id != "" && secret != "" -> { 304 + // Use environment variables if provided (backwards compatibility) 305 + logging.log( 306 + logging.Info, 307 + "[oauth] Using OAuth credentials from environment variables", 308 + ) 309 + handlers.OAuthConfig( 310 + client_id: id, 311 + client_secret: secret, 312 + redirect_uri: oauth_redirect_uri, 313 + auth_url: auth_base_url, 314 + ) 315 + } 316 + _, _ -> { 317 + // Try auto-registration if enabled 318 + case enable_auto_register { 319 + True -> { 320 + logging.log( 321 + logging.Info, 322 + "[oauth] Auto-registration enabled, checking for stored credentials...", 323 + ) 324 + 325 + case 326 + registration.ensure_oauth_client( 327 + db, 328 + auth_base_url, 329 + oauth_redirect_uri, 330 + "Quickslice Server", 331 + ) 332 + { 333 + Ok(config) -> config 334 + Error(err) -> { 335 + logging.log( 336 + logging.Error, 337 + "[oauth] OAuth auto-registration failed: " <> err, 338 + ) 339 + logging.log( 340 + logging.Warning, 341 + "[oauth] Starting retry actor to attempt registration in background...", 342 + ) 343 + 344 + // Start retry actor in background 345 + case 346 + registration.start_retry_actor( 347 + db, 348 + auth_base_url, 349 + oauth_redirect_uri, 350 + "Quickslice Server", 351 + ) 352 + { 353 + Ok(retry_subject) -> { 354 + logging.log( 355 + logging.Info, 356 + "[oauth] Retry actor started successfully", 357 + ) 358 + // Trigger first retry attempt immediately 359 + registration.trigger_retry(retry_subject) 360 + } 361 + Error(_) -> { 362 + logging.log( 363 + logging.Error, 364 + "[oauth] Failed to start retry actor", 365 + ) 366 + } 367 + } 368 + 369 + // Return empty credentials for now 370 + logging.log( 371 + logging.Warning, 372 + "[oauth] Server will start without OAuth support until registration succeeds", 373 + ) 374 + handlers.OAuthConfig( 375 + client_id: "", 376 + client_secret: "", 377 + redirect_uri: oauth_redirect_uri, 378 + auth_url: auth_base_url, 379 + ) 380 + } 381 + } 382 + } 383 + False -> { 384 + // Auto-registration disabled, use empty credentials 385 + logging.log( 386 + logging.Info, 387 + "[oauth] Auto-registration disabled, OAuth will not be available", 388 + ) 389 + handlers.OAuthConfig( 390 + client_id: "", 391 + client_secret: "", 392 + redirect_uri: oauth_redirect_uri, 393 + auth_url: auth_base_url, 394 + ) 395 + } 396 + } 397 + } 398 + } 309 399 310 400 // Get HOST and PORT from environment variables or use defaults 311 401 let host = case envoy.get("HOST") {
+63 -53
server/src/settings_handler.gleam
··· 45 45 Error(_) -> #(option.None, False) 46 46 } 47 47 48 + // Require admin access for the entire settings page 49 + case user_is_admin { 50 + False -> { 51 + logging.log(logging.Warning, "[settings] Non-admin user attempted to access settings page") 52 + wisp.redirect("/") 53 + } 54 + True -> handle_admin_request(req, ctx, current_user) 55 + } 56 + } 57 + 58 + fn handle_admin_request( 59 + req: wisp.Request, 60 + ctx: Context, 61 + current_user: option.Option(#(String, String)), 62 + ) -> wisp.Response { 48 63 case req.method { 49 64 gleam_http.Get -> { 50 65 // Extract flash messages if present 51 66 use flash_kind, flash_message <- wisp_flash.get_flash(req) 52 67 53 - settings.view(ctx.db, current_user, user_is_admin, flash_kind, flash_message) 68 + settings.view(ctx.db, current_user, flash_kind, flash_message) 54 69 |> element.to_document_string 55 70 |> wisp.html_response(200) 56 71 } ··· 61 76 // Check if this is a reset action 62 77 case list.key_find(form_data.values, "action") { 63 78 Ok("reset") -> { 64 - // Handle reset action 65 - handle_reset(req, form_data, ctx, user_is_admin) 79 + // Handle reset action (admin-only, already verified) 80 + handle_reset(req, form_data, ctx) 66 81 } 67 82 _ -> { 68 83 // Check if this is a lexicons upload ··· 266 281 req: wisp.Request, 267 282 form_data: wisp.FormData, 268 283 ctx: Context, 269 - is_admin: Bool, 270 284 ) -> wisp.Response { 271 - // Check admin access 272 - case is_admin { 273 - False -> { 274 - logging.log(logging.Error, "[settings] Non-admin user attempted reset") 275 - wisp.redirect("/settings") 276 - |> wisp_flash.set_flash(req, "error", "Unauthorized: Admin access required") 277 - } 278 - True -> { 279 - // Verify confirmation 280 - case list.key_find(form_data.values, "confirm") { 281 - Ok("RESET") -> { 282 - // Delete all data 283 - let domain_result = database.delete_domain_authority(ctx.db) 284 - let lexicons_result = database.delete_all_lexicons(ctx.db) 285 - let records_result = database.delete_all_records(ctx.db) 286 - let actors_result = database.delete_all_actors(ctx.db) 285 + // Admin access already verified by page-level check 286 + // Verify confirmation 287 + case list.key_find(form_data.values, "confirm") { 288 + Ok("RESET") -> { 289 + // Delete all data 290 + let domain_result = database.delete_domain_authority(ctx.db) 291 + let lexicons_result = database.delete_all_lexicons(ctx.db) 292 + let records_result = database.delete_all_records(ctx.db) 293 + let actors_result = database.delete_all_actors(ctx.db) 294 + let oauth_result = database.delete_oauth_credentials(ctx.db) 287 295 288 - case domain_result, lexicons_result, records_result, actors_result { 289 - Ok(_), Ok(_), Ok(_), Ok(_) -> { 290 - // Reload config cache 291 - let _ = config.reload(ctx.config, ctx.db) 296 + case domain_result, lexicons_result, records_result, actors_result, oauth_result { 297 + Ok(_), Ok(_), Ok(_), Ok(_), Ok(_) -> { 298 + logging.log( 299 + logging.Info, 300 + "[settings] OAuth credentials deleted, re-registration will occur on next interaction", 301 + ) 302 + // Reload config cache 303 + let _ = config.reload(ctx.config, ctx.db) 292 304 293 - // Restart Jetstream consumer if it exists 294 - let restart_message = case ctx.jetstream_consumer { 295 - option.Some(consumer) -> { 296 - case jetstream_consumer.restart(consumer) { 297 - Ok(_) -> "All data has been reset successfully" 298 - Error(_) -> 299 - "Data reset completed (Jetstream consumer may need manual restart)" 300 - } 301 - } 302 - option.None -> "All data has been reset successfully" 305 + // Restart Jetstream consumer if it exists 306 + let restart_message = case ctx.jetstream_consumer { 307 + option.Some(consumer) -> { 308 + case jetstream_consumer.restart(consumer) { 309 + Ok(_) -> "All data has been reset successfully" 310 + Error(_) -> 311 + "Data reset completed (Jetstream consumer may need manual restart)" 303 312 } 304 - 305 - logging.log(logging.Info, "[settings] System reset completed") 306 - wisp.redirect("/settings") 307 - |> wisp_flash.set_flash(req, "success", restart_message) 308 313 } 309 - _, _, _, _ -> { 310 - logging.log(logging.Error, "[settings] Failed to reset some data") 311 - wisp.redirect("/settings") 312 - |> wisp_flash.set_flash(req, "error", "Failed to reset all data") 313 - } 314 + option.None -> "All data has been reset successfully" 314 315 } 316 + 317 + logging.log(logging.Info, "[settings] System reset completed") 318 + wisp.redirect("/settings") 319 + |> wisp_flash.set_flash(req, "success", restart_message) 315 320 } 316 - _ -> { 317 - logging.log( 318 - logging.Warning, 319 - "[settings] Reset attempted without proper confirmation", 320 - ) 321 + _, _, _, _, _ -> { 322 + logging.log(logging.Error, "[settings] Failed to reset some data") 321 323 wisp.redirect("/settings") 322 - |> wisp_flash.set_flash( 323 - req, 324 - "error", 325 - "Confirmation failed: Please type RESET exactly", 326 - ) 324 + |> wisp_flash.set_flash(req, "error", "Failed to reset all data") 327 325 } 328 326 } 327 + } 328 + _ -> { 329 + logging.log( 330 + logging.Warning, 331 + "[settings] Reset attempted without proper confirmation", 332 + ) 333 + wisp.redirect("/settings") 334 + |> wisp_flash.set_flash( 335 + req, 336 + "error", 337 + "Confirmation failed: Please type RESET exactly", 338 + ) 329 339 } 330 340 } 331 341 }