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.

docs: add OAuth scopes implementation plan

+1559
+1559
docs/plans/2025-12-01-oauth-scopes.md
··· 1 + # ATProto OAuth Scopes Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Implement full ATProto OAuth scope parsing and validation at all OAuth touchpoints. 6 + 7 + **Architecture:** Create a scope parser module with typed Gleam representations for all ATProto scope kinds (static, account, identity, repo, blob, rpc, include). Integrate validation at registration, authorization, token exchange, and refresh endpoints. 8 + 9 + **Tech Stack:** Gleam, gleeunit for testing 10 + 11 + --- 12 + 13 + ## Task 1: Create Scope Types Module 14 + 15 + **Files:** 16 + - Create: `server/src/lib/oauth/scopes/types.gleam` 17 + - Test: `server/test/oauth/scopes/types_test.gleam` 18 + 19 + **Step 1: Write the failing test for static scopes** 20 + 21 + Create `server/test/oauth/scopes/types_test.gleam`: 22 + 23 + ```gleam 24 + import gleeunit/should 25 + import lib/oauth/scopes/types 26 + 27 + pub fn static_scope_atproto_test() { 28 + types.Atproto 29 + |> types.static_scope_to_string 30 + |> should.equal("atproto") 31 + } 32 + 33 + pub fn static_scope_transition_generic_test() { 34 + types.TransitionGeneric 35 + |> types.static_scope_to_string 36 + |> should.equal("transition:generic") 37 + } 38 + 39 + pub fn static_scope_transition_email_test() { 40 + types.TransitionEmail 41 + |> types.static_scope_to_string 42 + |> should.equal("transition:email") 43 + } 44 + 45 + pub fn static_scope_transition_chat_bsky_test() { 46 + types.TransitionChatBsky 47 + |> types.static_scope_to_string 48 + |> should.equal("transition:chat.bsky") 49 + } 50 + ``` 51 + 52 + **Step 2: Run test to verify it fails** 53 + 54 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 55 + Expected: FAIL with "module lib/oauth/scopes/types not found" 56 + 57 + **Step 3: Write minimal implementation** 58 + 59 + Create `server/src/lib/oauth/scopes/types.gleam`: 60 + 61 + ```gleam 62 + /// ATProto OAuth scope types 63 + /// Reference: https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-scopes 64 + 65 + /// Static ATProto scopes 66 + pub type StaticScope { 67 + Atproto 68 + TransitionEmail 69 + TransitionGeneric 70 + TransitionChatBsky 71 + } 72 + 73 + /// Actions for repo and account scopes 74 + pub type Action { 75 + Create 76 + Update 77 + Delete 78 + Read 79 + Manage 80 + } 81 + 82 + /// Account attributes 83 + pub type AccountAttribute { 84 + Email 85 + Repo 86 + Status 87 + } 88 + 89 + /// Identity attributes 90 + pub type IdentityAttribute { 91 + Handle 92 + All 93 + } 94 + 95 + /// Account scope: account:email?action=read 96 + pub type AccountScope { 97 + AccountScope(attribute: AccountAttribute, action: Action) 98 + } 99 + 100 + /// Identity scope: identity:handle or identity:* 101 + pub type IdentityScope { 102 + IdentityScope(attribute: IdentityAttribute) 103 + } 104 + 105 + /// Repo scope: repo:app.bsky.feed.post?action=create 106 + pub type RepoScope { 107 + RepoScope(collection: String, actions: List(Action)) 108 + } 109 + 110 + /// Blob scope: blob:image/* or blob:*/* 111 + pub type BlobScope { 112 + BlobScope(mime_type: String) 113 + } 114 + 115 + /// RPC scope: rpc:app.bsky.feed.getFeed?aud=did:web:bsky.app 116 + pub type RpcScope { 117 + RpcScope(methods: List(String), audience: String) 118 + } 119 + 120 + /// Include scope: include:app.bsky.feed?aud=did:web:... 121 + pub type IncludeScope { 122 + IncludeScope(nsid: String, audience: Option(String)) 123 + } 124 + 125 + import gleam/option.{type Option} 126 + 127 + /// Union of all scope types 128 + pub type Scope { 129 + Static(StaticScope) 130 + Account(AccountScope) 131 + Identity(IdentityScope) 132 + Repo(RepoScope) 133 + Blob(BlobScope) 134 + Rpc(RpcScope) 135 + Include(IncludeScope) 136 + } 137 + 138 + /// Convert static scope to string 139 + pub fn static_scope_to_string(scope: StaticScope) -> String { 140 + case scope { 141 + Atproto -> "atproto" 142 + TransitionEmail -> "transition:email" 143 + TransitionGeneric -> "transition:generic" 144 + TransitionChatBsky -> "transition:chat.bsky" 145 + } 146 + } 147 + ``` 148 + 149 + **Step 4: Run test to verify it passes** 150 + 151 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 152 + Expected: PASS 153 + 154 + **Step 5: Commit** 155 + 156 + ```bash 157 + git add server/src/lib/oauth/scopes/types.gleam server/test/oauth/scopes/types_test.gleam 158 + git commit -m "feat(oauth): add scope type definitions" 159 + ``` 160 + 161 + --- 162 + 163 + ## Task 2: Add Scope-to-String Conversions 164 + 165 + **Files:** 166 + - Modify: `server/src/lib/oauth/scopes/types.gleam` 167 + - Modify: `server/test/oauth/scopes/types_test.gleam` 168 + 169 + **Step 1: Write the failing test for action_to_string** 170 + 171 + Add to `server/test/oauth/scopes/types_test.gleam`: 172 + 173 + ```gleam 174 + pub fn action_to_string_create_test() { 175 + types.Create 176 + |> types.action_to_string 177 + |> should.equal("create") 178 + } 179 + 180 + pub fn action_to_string_read_test() { 181 + types.Read 182 + |> types.action_to_string 183 + |> should.equal("read") 184 + } 185 + 186 + pub fn action_to_string_manage_test() { 187 + types.Manage 188 + |> types.action_to_string 189 + |> should.equal("manage") 190 + } 191 + ``` 192 + 193 + **Step 2: Run test to verify it fails** 194 + 195 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 196 + Expected: FAIL with "action_to_string not found" 197 + 198 + **Step 3: Write minimal implementation** 199 + 200 + Add to `server/src/lib/oauth/scopes/types.gleam`: 201 + 202 + ```gleam 203 + /// Convert action to string 204 + pub fn action_to_string(action: Action) -> String { 205 + case action { 206 + Create -> "create" 207 + Update -> "update" 208 + Delete -> "delete" 209 + Read -> "read" 210 + Manage -> "manage" 211 + } 212 + } 213 + ``` 214 + 215 + **Step 4: Run test to verify it passes** 216 + 217 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 218 + Expected: PASS 219 + 220 + **Step 5: Commit** 221 + 222 + ```bash 223 + git add server/src/lib/oauth/scopes/types.gleam server/test/oauth/scopes/types_test.gleam 224 + git commit -m "feat(oauth): add action_to_string conversion" 225 + ``` 226 + 227 + --- 228 + 229 + ## Task 3: Create Parse Error Types 230 + 231 + **Files:** 232 + - Create: `server/src/lib/oauth/scopes/parse_error.gleam` 233 + - Test: `server/test/oauth/scopes/parse_error_test.gleam` 234 + 235 + **Step 1: Write the failing test** 236 + 237 + Create `server/test/oauth/scopes/parse_error_test.gleam`: 238 + 239 + ```gleam 240 + import gleeunit/should 241 + import lib/oauth/scopes/parse_error 242 + 243 + pub fn invalid_scope_format_to_string_test() { 244 + parse_error.InvalidScopeFormat("repo:", "missing collection") 245 + |> parse_error.to_string 246 + |> should.equal("Invalid scope format: 'repo:' - missing collection") 247 + } 248 + 249 + pub fn invalid_action_to_string_test() { 250 + parse_error.InvalidAction("foo") 251 + |> parse_error.to_string 252 + |> should.equal("Unknown action 'foo', expected: create, update, delete, read, manage") 253 + } 254 + 255 + pub fn invalid_rpc_scope_to_string_test() { 256 + parse_error.InvalidRpcScope("wildcard method requires specific audience") 257 + |> parse_error.to_string 258 + |> should.equal("Invalid RPC scope: wildcard method requires specific audience") 259 + } 260 + ``` 261 + 262 + **Step 2: Run test to verify it fails** 263 + 264 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 265 + Expected: FAIL with "module lib/oauth/scopes/parse_error not found" 266 + 267 + **Step 3: Write minimal implementation** 268 + 269 + Create `server/src/lib/oauth/scopes/parse_error.gleam`: 270 + 271 + ```gleam 272 + /// Parse error types for OAuth scope parsing 273 + 274 + pub type ParseError { 275 + InvalidScopeFormat(scope: String, reason: String) 276 + InvalidAction(action: String) 277 + InvalidAttribute(attribute: String) 278 + InvalidMimeType(mime: String) 279 + InvalidRpcScope(reason: String) 280 + } 281 + 282 + /// Convert parse error to user-facing string 283 + pub fn to_string(error: ParseError) -> String { 284 + case error { 285 + InvalidScopeFormat(scope, reason) -> 286 + "Invalid scope format: '" <> scope <> "' - " <> reason 287 + InvalidAction(action) -> 288 + "Unknown action '" <> action <> "', expected: create, update, delete, read, manage" 289 + InvalidAttribute(attr) -> 290 + "Unknown attribute '" <> attr <> "'" 291 + InvalidMimeType(mime) -> 292 + "Invalid MIME type '" <> mime <> "', expected format: type/subtype" 293 + InvalidRpcScope(reason) -> 294 + "Invalid RPC scope: " <> reason 295 + } 296 + } 297 + ``` 298 + 299 + **Step 4: Run test to verify it passes** 300 + 301 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 302 + Expected: PASS 303 + 304 + **Step 5: Commit** 305 + 306 + ```bash 307 + git add server/src/lib/oauth/scopes/parse_error.gleam server/test/oauth/scopes/parse_error_test.gleam 308 + git commit -m "feat(oauth): add scope parse error types" 309 + ``` 310 + 311 + --- 312 + 313 + ## Task 4: Create Parser Module - Static Scopes 314 + 315 + **Files:** 316 + - Create: `server/src/lib/oauth/scopes/parser.gleam` 317 + - Test: `server/test/oauth/scopes/parser_test.gleam` 318 + 319 + **Step 1: Write the failing test for parsing static scopes** 320 + 321 + Create `server/test/oauth/scopes/parser_test.gleam`: 322 + 323 + ```gleam 324 + import gleam/option.{None, Some} 325 + import gleeunit/should 326 + import lib/oauth/scopes/parser 327 + import lib/oauth/scopes/types 328 + 329 + pub fn parse_atproto_test() { 330 + parser.parse_scope("atproto") 331 + |> should.be_ok 332 + |> should.equal(types.Static(types.Atproto)) 333 + } 334 + 335 + pub fn parse_transition_generic_test() { 336 + parser.parse_scope("transition:generic") 337 + |> should.be_ok 338 + |> should.equal(types.Static(types.TransitionGeneric)) 339 + } 340 + 341 + pub fn parse_transition_email_test() { 342 + parser.parse_scope("transition:email") 343 + |> should.be_ok 344 + |> should.equal(types.Static(types.TransitionEmail)) 345 + } 346 + 347 + pub fn parse_transition_chat_bsky_test() { 348 + parser.parse_scope("transition:chat.bsky") 349 + |> should.be_ok 350 + |> should.equal(types.Static(types.TransitionChatBsky)) 351 + } 352 + ``` 353 + 354 + **Step 2: Run test to verify it fails** 355 + 356 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 357 + Expected: FAIL with "module lib/oauth/scopes/parser not found" 358 + 359 + **Step 3: Write minimal implementation** 360 + 361 + Create `server/src/lib/oauth/scopes/parser.gleam`: 362 + 363 + ```gleam 364 + /// ATProto OAuth scope parser 365 + 366 + import lib/oauth/scopes/parse_error.{type ParseError, InvalidScopeFormat} 367 + import lib/oauth/scopes/types.{ 368 + type Scope, type StaticScope, Atproto, Static, TransitionChatBsky, 369 + TransitionEmail, TransitionGeneric, 370 + } 371 + 372 + /// Parse a single scope token 373 + pub fn parse_scope(token: String) -> Result(Scope, ParseError) { 374 + case parse_static(token) { 375 + Ok(scope) -> Ok(Static(scope)) 376 + Error(Nil) -> Error(InvalidScopeFormat(token, "unknown scope type")) 377 + } 378 + } 379 + 380 + /// Try to parse as static scope 381 + fn parse_static(token: String) -> Result(StaticScope, Nil) { 382 + case token { 383 + "atproto" -> Ok(Atproto) 384 + "transition:email" -> Ok(TransitionEmail) 385 + "transition:generic" -> Ok(TransitionGeneric) 386 + "transition:chat.bsky" -> Ok(TransitionChatBsky) 387 + _ -> Error(Nil) 388 + } 389 + } 390 + ``` 391 + 392 + **Step 4: Run test to verify it passes** 393 + 394 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 395 + Expected: PASS 396 + 397 + **Step 5: Commit** 398 + 399 + ```bash 400 + git add server/src/lib/oauth/scopes/parser.gleam server/test/oauth/scopes/parser_test.gleam 401 + git commit -m "feat(oauth): add parser for static scopes" 402 + ``` 403 + 404 + --- 405 + 406 + ## Task 5: Parse Account Scopes 407 + 408 + **Files:** 409 + - Modify: `server/src/lib/oauth/scopes/parser.gleam` 410 + - Modify: `server/test/oauth/scopes/parser_test.gleam` 411 + 412 + **Step 1: Write the failing test** 413 + 414 + Add to `server/test/oauth/scopes/parser_test.gleam`: 415 + 416 + ```gleam 417 + pub fn parse_account_email_test() { 418 + parser.parse_scope("account:email") 419 + |> should.be_ok 420 + |> should.equal(types.Account(types.AccountScope( 421 + attribute: types.Email, 422 + action: types.Read, 423 + ))) 424 + } 425 + 426 + pub fn parse_account_email_with_action_test() { 427 + parser.parse_scope("account:email?action=manage") 428 + |> should.be_ok 429 + |> should.equal(types.Account(types.AccountScope( 430 + attribute: types.Email, 431 + action: types.Manage, 432 + ))) 433 + } 434 + 435 + pub fn parse_account_repo_test() { 436 + parser.parse_scope("account:repo") 437 + |> should.be_ok 438 + |> should.equal(types.Account(types.AccountScope( 439 + attribute: types.Repo, 440 + action: types.Read, 441 + ))) 442 + } 443 + 444 + pub fn parse_account_status_test() { 445 + parser.parse_scope("account:status?action=manage") 446 + |> should.be_ok 447 + |> should.equal(types.Account(types.AccountScope( 448 + attribute: types.Status, 449 + action: types.Manage, 450 + ))) 451 + } 452 + ``` 453 + 454 + **Step 2: Run test to verify it fails** 455 + 456 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 457 + Expected: FAIL - account scopes not yet parsed 458 + 459 + **Step 3: Write minimal implementation** 460 + 461 + Update `server/src/lib/oauth/scopes/parser.gleam` to add account parsing: 462 + 463 + ```gleam 464 + /// ATProto OAuth scope parser 465 + 466 + import gleam/list 467 + import gleam/option.{type Option, None, Some} 468 + import gleam/result 469 + import gleam/string 470 + import gleam/uri 471 + import lib/oauth/scopes/parse_error.{ 472 + type ParseError, InvalidAction, InvalidAttribute, InvalidScopeFormat, 473 + } 474 + import lib/oauth/scopes/types.{ 475 + type AccountAttribute, type Action, type Scope, type StaticScope, Account, 476 + AccountScope, Atproto, Create, Delete, Email, Manage, Read, Repo, Static, 477 + Status, TransitionChatBsky, TransitionEmail, TransitionGeneric, Update, 478 + } 479 + 480 + /// Parse a single scope token 481 + pub fn parse_scope(token: String) -> Result(Scope, ParseError) { 482 + case parse_static(token) { 483 + Ok(scope) -> Ok(Static(scope)) 484 + Error(Nil) -> parse_parameterized(token) 485 + } 486 + } 487 + 488 + /// Try to parse as static scope 489 + fn parse_static(token: String) -> Result(StaticScope, Nil) { 490 + case token { 491 + "atproto" -> Ok(Atproto) 492 + "transition:email" -> Ok(TransitionEmail) 493 + "transition:generic" -> Ok(TransitionGeneric) 494 + "transition:chat.bsky" -> Ok(TransitionChatBsky) 495 + _ -> Error(Nil) 496 + } 497 + } 498 + 499 + /// Parse parameterized scope (prefix:value?params) 500 + fn parse_parameterized(token: String) -> Result(Scope, ParseError) { 501 + case string.split_once(token, ":") { 502 + Ok(#("account", rest)) -> parse_account_scope(token, rest) 503 + Ok(#(prefix, _)) -> 504 + Error(InvalidScopeFormat(token, "unknown prefix: " <> prefix)) 505 + Error(Nil) -> Error(InvalidScopeFormat(token, "missing scope prefix")) 506 + } 507 + } 508 + 509 + /// Parse account scope: account:email?action=manage 510 + fn parse_account_scope( 511 + original: String, 512 + rest: String, 513 + ) -> Result(Scope, ParseError) { 514 + let #(attr_str, query) = split_query(rest) 515 + 516 + use attribute <- result.try(parse_account_attribute(original, attr_str)) 517 + 518 + let action = case get_query_param(query, "action") { 519 + Some("read") -> Ok(Read) 520 + Some("manage") -> Ok(Manage) 521 + Some(other) -> Error(InvalidAction(other)) 522 + None -> Ok(Read) 523 + } 524 + 525 + use act <- result.try(action) 526 + 527 + Ok(Account(AccountScope(attribute: attribute, action: act))) 528 + } 529 + 530 + /// Parse account attribute 531 + fn parse_account_attribute( 532 + original: String, 533 + attr: String, 534 + ) -> Result(AccountAttribute, ParseError) { 535 + case attr { 536 + "email" -> Ok(Email) 537 + "repo" -> Ok(Repo) 538 + "status" -> Ok(Status) 539 + _ -> Error(InvalidAttribute(attr)) 540 + } 541 + } 542 + 543 + /// Split value from query string 544 + fn split_query(s: String) -> #(String, Option(String)) { 545 + case string.split_once(s, "?") { 546 + Ok(#(value, query)) -> #(value, Some(query)) 547 + Error(Nil) -> #(s, None) 548 + } 549 + } 550 + 551 + /// Get a query parameter value 552 + fn get_query_param(query: Option(String), key: String) -> Option(String) { 553 + case query { 554 + None -> None 555 + Some(q) -> { 556 + case uri.parse_query(q) { 557 + Ok(params) -> 558 + params 559 + |> list.find(fn(p) { p.0 == key }) 560 + |> result.map(fn(p) { p.1 }) 561 + |> option.from_result 562 + Error(_) -> None 563 + } 564 + } 565 + } 566 + } 567 + ``` 568 + 569 + **Step 4: Run test to verify it passes** 570 + 571 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 572 + Expected: PASS 573 + 574 + **Step 5: Commit** 575 + 576 + ```bash 577 + git add server/src/lib/oauth/scopes/parser.gleam server/test/oauth/scopes/parser_test.gleam 578 + git commit -m "feat(oauth): add account scope parsing" 579 + ``` 580 + 581 + --- 582 + 583 + ## Task 6: Parse Identity Scopes 584 + 585 + **Files:** 586 + - Modify: `server/src/lib/oauth/scopes/parser.gleam` 587 + - Modify: `server/test/oauth/scopes/parser_test.gleam` 588 + 589 + **Step 1: Write the failing test** 590 + 591 + Add to `server/test/oauth/scopes/parser_test.gleam`: 592 + 593 + ```gleam 594 + pub fn parse_identity_handle_test() { 595 + parser.parse_scope("identity:handle") 596 + |> should.be_ok 597 + |> should.equal(types.Identity(types.IdentityScope(attribute: types.Handle))) 598 + } 599 + 600 + pub fn parse_identity_all_test() { 601 + parser.parse_scope("identity:*") 602 + |> should.be_ok 603 + |> should.equal(types.Identity(types.IdentityScope(attribute: types.All))) 604 + } 605 + ``` 606 + 607 + **Step 2: Run test to verify it fails** 608 + 609 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 610 + Expected: FAIL - identity scopes not yet parsed 611 + 612 + **Step 3: Write minimal implementation** 613 + 614 + Add to `server/src/lib/oauth/scopes/parser.gleam` in `parse_parameterized`: 615 + 616 + ```gleam 617 + fn parse_parameterized(token: String) -> Result(Scope, ParseError) { 618 + case string.split_once(token, ":") { 619 + Ok(#("account", rest)) -> parse_account_scope(token, rest) 620 + Ok(#("identity", rest)) -> parse_identity_scope(token, rest) 621 + Ok(#(prefix, _)) -> 622 + Error(InvalidScopeFormat(token, "unknown prefix: " <> prefix)) 623 + Error(Nil) -> Error(InvalidScopeFormat(token, "missing scope prefix")) 624 + } 625 + } 626 + ``` 627 + 628 + Add the parsing function: 629 + 630 + ```gleam 631 + /// Parse identity scope: identity:handle or identity:* 632 + fn parse_identity_scope( 633 + original: String, 634 + rest: String, 635 + ) -> Result(Scope, ParseError) { 636 + let #(attr_str, _query) = split_query(rest) 637 + 638 + case attr_str { 639 + "handle" -> Ok(Identity(IdentityScope(attribute: Handle))) 640 + "*" -> Ok(Identity(IdentityScope(attribute: All))) 641 + _ -> Error(InvalidAttribute(attr_str)) 642 + } 643 + } 644 + ``` 645 + 646 + Also update imports: 647 + 648 + ```gleam 649 + import lib/oauth/scopes/types.{ 650 + type AccountAttribute, type Action, type Scope, type StaticScope, Account, 651 + AccountScope, All, Atproto, Create, Delete, Email, Handle, Identity, 652 + IdentityScope, Manage, Read, Repo, Static, Status, TransitionChatBsky, 653 + TransitionEmail, TransitionGeneric, Update, 654 + } 655 + ``` 656 + 657 + **Step 4: Run test to verify it passes** 658 + 659 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 660 + Expected: PASS 661 + 662 + **Step 5: Commit** 663 + 664 + ```bash 665 + git add server/src/lib/oauth/scopes/parser.gleam server/test/oauth/scopes/parser_test.gleam 666 + git commit -m "feat(oauth): add identity scope parsing" 667 + ``` 668 + 669 + --- 670 + 671 + ## Task 7: Parse Repo Scopes 672 + 673 + **Files:** 674 + - Modify: `server/src/lib/oauth/scopes/parser.gleam` 675 + - Modify: `server/test/oauth/scopes/parser_test.gleam` 676 + 677 + **Step 1: Write the failing test** 678 + 679 + Add to `server/test/oauth/scopes/parser_test.gleam`: 680 + 681 + ```gleam 682 + pub fn parse_repo_wildcard_test() { 683 + parser.parse_scope("repo:*") 684 + |> should.be_ok 685 + |> should.equal(types.Repo(types.RepoScope( 686 + collection: "*", 687 + actions: [types.Create, types.Update, types.Delete], 688 + ))) 689 + } 690 + 691 + pub fn parse_repo_specific_collection_test() { 692 + parser.parse_scope("repo:app.bsky.feed.post") 693 + |> should.be_ok 694 + |> should.equal(types.Repo(types.RepoScope( 695 + collection: "app.bsky.feed.post", 696 + actions: [types.Create, types.Update, types.Delete], 697 + ))) 698 + } 699 + 700 + pub fn parse_repo_with_single_action_test() { 701 + parser.parse_scope("repo:app.bsky.feed.post?action=create") 702 + |> should.be_ok 703 + |> should.equal(types.Repo(types.RepoScope( 704 + collection: "app.bsky.feed.post", 705 + actions: [types.Create], 706 + ))) 707 + } 708 + 709 + pub fn parse_repo_with_multiple_actions_test() { 710 + parser.parse_scope("repo:*?action=create&action=delete") 711 + |> should.be_ok 712 + |> should.equal(types.Repo(types.RepoScope( 713 + collection: "*", 714 + actions: [types.Create, types.Delete], 715 + ))) 716 + } 717 + ``` 718 + 719 + **Step 2: Run test to verify it fails** 720 + 721 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 722 + Expected: FAIL - repo scopes not yet parsed 723 + 724 + **Step 3: Write minimal implementation** 725 + 726 + Add to `server/src/lib/oauth/scopes/parser.gleam` in `parse_parameterized`: 727 + 728 + ```gleam 729 + fn parse_parameterized(token: String) -> Result(Scope, ParseError) { 730 + case string.split_once(token, ":") { 731 + Ok(#("account", rest)) -> parse_account_scope(token, rest) 732 + Ok(#("identity", rest)) -> parse_identity_scope(token, rest) 733 + Ok(#("repo", rest)) -> parse_repo_scope(token, rest) 734 + Ok(#(prefix, _)) -> 735 + Error(InvalidScopeFormat(token, "unknown prefix: " <> prefix)) 736 + Error(Nil) -> Error(InvalidScopeFormat(token, "missing scope prefix")) 737 + } 738 + } 739 + ``` 740 + 741 + Add the parsing function: 742 + 743 + ```gleam 744 + /// Parse repo scope: repo:app.bsky.feed.post?action=create 745 + fn parse_repo_scope( 746 + original: String, 747 + rest: String, 748 + ) -> Result(Scope, ParseError) { 749 + let #(collection, query) = split_query(rest) 750 + 751 + case string.is_empty(collection) { 752 + True -> Error(InvalidScopeFormat(original, "missing collection")) 753 + False -> { 754 + let actions = case query { 755 + None -> [Create, Update, Delete] 756 + Some(q) -> parse_repo_actions(q) 757 + } 758 + 759 + case list.is_empty(actions) { 760 + True -> Error(InvalidScopeFormat(original, "no valid actions")) 761 + False -> Ok(Repo(RepoScope(collection: collection, actions: actions))) 762 + } 763 + } 764 + } 765 + } 766 + 767 + /// Parse actions from query string for repo scope 768 + fn parse_repo_actions(query: String) -> List(Action) { 769 + case uri.parse_query(query) { 770 + Ok(params) -> 771 + params 772 + |> list.filter_map(fn(p) { 773 + case p.0 { 774 + "action" -> 775 + case p.1 { 776 + "create" -> Ok(Create) 777 + "update" -> Ok(Update) 778 + "delete" -> Ok(Delete) 779 + _ -> Error(Nil) 780 + } 781 + _ -> Error(Nil) 782 + } 783 + }) 784 + Error(_) -> [] 785 + } 786 + } 787 + ``` 788 + 789 + Also update imports to include `Repo` and `RepoScope`. 790 + 791 + **Step 4: Run test to verify it passes** 792 + 793 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 794 + Expected: PASS 795 + 796 + **Step 5: Commit** 797 + 798 + ```bash 799 + git add server/src/lib/oauth/scopes/parser.gleam server/test/oauth/scopes/parser_test.gleam 800 + git commit -m "feat(oauth): add repo scope parsing" 801 + ``` 802 + 803 + --- 804 + 805 + ## Task 8: Parse Blob Scopes 806 + 807 + **Files:** 808 + - Modify: `server/src/lib/oauth/scopes/parser.gleam` 809 + - Modify: `server/test/oauth/scopes/parser_test.gleam` 810 + 811 + **Step 1: Write the failing test** 812 + 813 + Add to `server/test/oauth/scopes/parser_test.gleam`: 814 + 815 + ```gleam 816 + pub fn parse_blob_wildcard_test() { 817 + parser.parse_scope("blob:*/*") 818 + |> should.be_ok 819 + |> should.equal(types.Blob(types.BlobScope(mime_type: "*/*"))) 820 + } 821 + 822 + pub fn parse_blob_image_wildcard_test() { 823 + parser.parse_scope("blob:image/*") 824 + |> should.be_ok 825 + |> should.equal(types.Blob(types.BlobScope(mime_type: "image/*"))) 826 + } 827 + 828 + pub fn parse_blob_specific_type_test() { 829 + parser.parse_scope("blob:image/png") 830 + |> should.be_ok 831 + |> should.equal(types.Blob(types.BlobScope(mime_type: "image/png"))) 832 + } 833 + 834 + pub fn parse_blob_invalid_mime_test() { 835 + parser.parse_scope("blob:invalid") 836 + |> should.be_error 837 + } 838 + ``` 839 + 840 + **Step 2: Run test to verify it fails** 841 + 842 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 843 + Expected: FAIL - blob scopes not yet parsed 844 + 845 + **Step 3: Write minimal implementation** 846 + 847 + Add to `parse_parameterized`: 848 + 849 + ```gleam 850 + Ok(#("blob", rest)) -> parse_blob_scope(token, rest) 851 + ``` 852 + 853 + Add the parsing function: 854 + 855 + ```gleam 856 + /// Parse blob scope: blob:image/* or blob:*/* 857 + fn parse_blob_scope( 858 + original: String, 859 + mime_type: String, 860 + ) -> Result(Scope, ParseError) { 861 + case validate_mime_type(mime_type) { 862 + True -> Ok(Blob(BlobScope(mime_type: mime_type))) 863 + False -> Error(InvalidMimeType(mime_type)) 864 + } 865 + } 866 + 867 + /// Validate MIME type format 868 + fn validate_mime_type(mime: String) -> Bool { 869 + case string.split(mime, "/") { 870 + [type_part, subtype] -> 871 + !string.is_empty(type_part) && !string.is_empty(subtype) 872 + _ -> False 873 + } 874 + } 875 + ``` 876 + 877 + Update imports to include `Blob`, `BlobScope`, and `InvalidMimeType`. 878 + 879 + **Step 4: Run test to verify it passes** 880 + 881 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 882 + Expected: PASS 883 + 884 + **Step 5: Commit** 885 + 886 + ```bash 887 + git add server/src/lib/oauth/scopes/parser.gleam server/test/oauth/scopes/parser_test.gleam 888 + git commit -m "feat(oauth): add blob scope parsing" 889 + ``` 890 + 891 + --- 892 + 893 + ## Task 9: Parse RPC Scopes 894 + 895 + **Files:** 896 + - Modify: `server/src/lib/oauth/scopes/parser.gleam` 897 + - Modify: `server/test/oauth/scopes/parser_test.gleam` 898 + 899 + **Step 1: Write the failing test** 900 + 901 + Add to `server/test/oauth/scopes/parser_test.gleam`: 902 + 903 + ```gleam 904 + pub fn parse_rpc_specific_method_test() { 905 + parser.parse_scope("rpc:app.bsky.feed.getFeed?aud=did:web:bsky.app") 906 + |> should.be_ok 907 + |> should.equal(types.Rpc(types.RpcScope( 908 + methods: ["app.bsky.feed.getFeed"], 909 + audience: "did:web:bsky.app", 910 + ))) 911 + } 912 + 913 + pub fn parse_rpc_wildcard_method_specific_aud_test() { 914 + parser.parse_scope("rpc:*?aud=did:web:api.bsky.app") 915 + |> should.be_ok 916 + |> should.equal(types.Rpc(types.RpcScope( 917 + methods: ["*"], 918 + audience: "did:web:api.bsky.app", 919 + ))) 920 + } 921 + 922 + pub fn parse_rpc_missing_aud_test() { 923 + parser.parse_scope("rpc:app.bsky.feed.getFeed") 924 + |> should.be_error 925 + } 926 + 927 + pub fn parse_rpc_wildcard_both_test() { 928 + // rpc:* with aud=* is invalid 929 + parser.parse_scope("rpc:*?aud=*") 930 + |> should.be_error 931 + } 932 + ``` 933 + 934 + **Step 2: Run test to verify it fails** 935 + 936 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 937 + Expected: FAIL - rpc scopes not yet parsed 938 + 939 + **Step 3: Write minimal implementation** 940 + 941 + Add to `parse_parameterized`: 942 + 943 + ```gleam 944 + Ok(#("rpc", rest)) -> parse_rpc_scope(token, rest) 945 + ``` 946 + 947 + Add the parsing function: 948 + 949 + ```gleam 950 + /// Parse rpc scope: rpc:app.bsky.feed.getFeed?aud=did:web:bsky.app 951 + fn parse_rpc_scope( 952 + original: String, 953 + rest: String, 954 + ) -> Result(Scope, ParseError) { 955 + let #(method, query) = split_query(rest) 956 + 957 + case string.is_empty(method) { 958 + True -> Error(InvalidScopeFormat(original, "missing method")) 959 + False -> { 960 + case get_query_param(query, "aud") { 961 + None -> Error(InvalidRpcScope("aud parameter is required")) 962 + Some(aud) -> { 963 + // Validate: can't have wildcard method with wildcard audience 964 + case method == "*" && aud == "*" { 965 + True -> 966 + Error(InvalidRpcScope( 967 + "wildcard method requires specific audience", 968 + )) 969 + False -> Ok(Rpc(RpcScope(methods: [method], audience: aud))) 970 + } 971 + } 972 + } 973 + } 974 + } 975 + } 976 + ``` 977 + 978 + Update imports to include `Rpc`, `RpcScope`, and `InvalidRpcScope`. 979 + 980 + **Step 4: Run test to verify it passes** 981 + 982 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 983 + Expected: PASS 984 + 985 + **Step 5: Commit** 986 + 987 + ```bash 988 + git add server/src/lib/oauth/scopes/parser.gleam server/test/oauth/scopes/parser_test.gleam 989 + git commit -m "feat(oauth): add rpc scope parsing" 990 + ``` 991 + 992 + --- 993 + 994 + ## Task 10: Parse Include Scopes 995 + 996 + **Files:** 997 + - Modify: `server/src/lib/oauth/scopes/parser.gleam` 998 + - Modify: `server/test/oauth/scopes/parser_test.gleam` 999 + 1000 + **Step 1: Write the failing test** 1001 + 1002 + Add to `server/test/oauth/scopes/parser_test.gleam`: 1003 + 1004 + ```gleam 1005 + pub fn parse_include_simple_test() { 1006 + parser.parse_scope("include:app.bsky.feed") 1007 + |> should.be_ok 1008 + |> should.equal(types.Include(types.IncludeScope( 1009 + nsid: "app.bsky.feed", 1010 + audience: None, 1011 + ))) 1012 + } 1013 + 1014 + pub fn parse_include_with_aud_test() { 1015 + parser.parse_scope("include:chat.bsky.moderation?aud=did:web:bsky.chat") 1016 + |> should.be_ok 1017 + |> should.equal(types.Include(types.IncludeScope( 1018 + nsid: "chat.bsky.moderation", 1019 + audience: Some("did:web:bsky.chat"), 1020 + ))) 1021 + } 1022 + ``` 1023 + 1024 + **Step 2: Run test to verify it fails** 1025 + 1026 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1027 + Expected: FAIL - include scopes not yet parsed 1028 + 1029 + **Step 3: Write minimal implementation** 1030 + 1031 + Add to `parse_parameterized`: 1032 + 1033 + ```gleam 1034 + Ok(#("include", rest)) -> parse_include_scope(token, rest) 1035 + ``` 1036 + 1037 + Add the parsing function: 1038 + 1039 + ```gleam 1040 + /// Parse include scope: include:app.bsky.feed?aud=did:web:... 1041 + fn parse_include_scope( 1042 + original: String, 1043 + rest: String, 1044 + ) -> Result(Scope, ParseError) { 1045 + let #(nsid, query) = split_query(rest) 1046 + 1047 + case string.is_empty(nsid) { 1048 + True -> Error(InvalidScopeFormat(original, "missing NSID")) 1049 + False -> { 1050 + let audience = get_query_param(query, "aud") 1051 + Ok(Include(IncludeScope(nsid: nsid, audience: audience))) 1052 + } 1053 + } 1054 + } 1055 + ``` 1056 + 1057 + Update imports to include `Include` and `IncludeScope`. 1058 + 1059 + **Step 4: Run test to verify it passes** 1060 + 1061 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1062 + Expected: PASS 1063 + 1064 + **Step 5: Commit** 1065 + 1066 + ```bash 1067 + git add server/src/lib/oauth/scopes/parser.gleam server/test/oauth/scopes/parser_test.gleam 1068 + git commit -m "feat(oauth): add include scope parsing" 1069 + ``` 1070 + 1071 + --- 1072 + 1073 + ## Task 11: Add parse_scopes Function 1074 + 1075 + **Files:** 1076 + - Modify: `server/src/lib/oauth/scopes/parser.gleam` 1077 + - Modify: `server/test/oauth/scopes/parser_test.gleam` 1078 + 1079 + **Step 1: Write the failing test** 1080 + 1081 + Add to `server/test/oauth/scopes/parser_test.gleam`: 1082 + 1083 + ```gleam 1084 + pub fn parse_scopes_multiple_test() { 1085 + parser.parse_scopes("atproto repo:* account:email") 1086 + |> should.be_ok 1087 + |> should.equal([ 1088 + types.Static(types.Atproto), 1089 + types.Repo(types.RepoScope( 1090 + collection: "*", 1091 + actions: [types.Create, types.Update, types.Delete], 1092 + )), 1093 + types.Account(types.AccountScope( 1094 + attribute: types.Email, 1095 + action: types.Read, 1096 + )), 1097 + ]) 1098 + } 1099 + 1100 + pub fn parse_scopes_empty_test() { 1101 + parser.parse_scopes("") 1102 + |> should.be_ok 1103 + |> should.equal([]) 1104 + } 1105 + 1106 + pub fn parse_scopes_single_invalid_fails_test() { 1107 + parser.parse_scopes("atproto invalid::: repo:*") 1108 + |> should.be_error 1109 + } 1110 + ``` 1111 + 1112 + **Step 2: Run test to verify it fails** 1113 + 1114 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1115 + Expected: FAIL - parse_scopes not defined 1116 + 1117 + **Step 3: Write minimal implementation** 1118 + 1119 + Add to `server/src/lib/oauth/scopes/parser.gleam`: 1120 + 1121 + ```gleam 1122 + /// Parse a space-separated scope string into list of Scope 1123 + pub fn parse_scopes(scope_string: String) -> Result(List(Scope), ParseError) { 1124 + case string.is_empty(string.trim(scope_string)) { 1125 + True -> Ok([]) 1126 + False -> { 1127 + scope_string 1128 + |> string.split(" ") 1129 + |> list.filter(fn(s) { !string.is_empty(s) }) 1130 + |> list.try_map(parse_scope) 1131 + } 1132 + } 1133 + } 1134 + ``` 1135 + 1136 + **Step 4: Run test to verify it passes** 1137 + 1138 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1139 + Expected: PASS 1140 + 1141 + **Step 5: Commit** 1142 + 1143 + ```bash 1144 + git add server/src/lib/oauth/scopes/parser.gleam server/test/oauth/scopes/parser_test.gleam 1145 + git commit -m "feat(oauth): add parse_scopes for space-separated scope strings" 1146 + ``` 1147 + 1148 + --- 1149 + 1150 + ## Task 12: Create Scope Validator Module 1151 + 1152 + **Files:** 1153 + - Create: `server/src/lib/oauth/scopes/validator.gleam` 1154 + - Test: `server/test/oauth/scopes/validator_test.gleam` 1155 + 1156 + **Step 1: Write the failing test** 1157 + 1158 + Create `server/test/oauth/scopes/validator_test.gleam`: 1159 + 1160 + ```gleam 1161 + import gleam/option.{None, Some} 1162 + import gleeunit/should 1163 + import lib/oauth/scopes/validator 1164 + import lib/oauth/types/error 1165 + 1166 + pub fn validate_scope_format_valid_test() { 1167 + validator.validate_scope_format("atproto repo:* account:email") 1168 + |> should.be_ok 1169 + } 1170 + 1171 + pub fn validate_scope_format_invalid_test() { 1172 + validator.validate_scope_format("atproto invalid:::") 1173 + |> should.be_error 1174 + } 1175 + 1176 + pub fn validate_scope_format_empty_test() { 1177 + validator.validate_scope_format("") 1178 + |> should.be_ok 1179 + } 1180 + ``` 1181 + 1182 + **Step 2: Run test to verify it fails** 1183 + 1184 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1185 + Expected: FAIL - module not found 1186 + 1187 + **Step 3: Write minimal implementation** 1188 + 1189 + Create `server/src/lib/oauth/scopes/validator.gleam`: 1190 + 1191 + ```gleam 1192 + /// OAuth scope validation 1193 + 1194 + import lib/oauth/scopes/parse_error 1195 + import lib/oauth/scopes/parser 1196 + import lib/oauth/scopes/types.{type Scope} 1197 + import lib/oauth/types/error.{type OAuthError, InvalidScope} 1198 + 1199 + /// Validate scope string format and parse into structured scopes 1200 + pub fn validate_scope_format(scope_string: String) -> Result(List(Scope), OAuthError) { 1201 + parser.parse_scopes(scope_string) 1202 + |> result.map_error(fn(e) { InvalidScope(parse_error.to_string(e)) }) 1203 + } 1204 + 1205 + import gleam/result 1206 + ``` 1207 + 1208 + **Step 4: Run test to verify it passes** 1209 + 1210 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1211 + Expected: PASS 1212 + 1213 + **Step 5: Commit** 1214 + 1215 + ```bash 1216 + git add server/src/lib/oauth/scopes/validator.gleam server/test/oauth/scopes/validator_test.gleam 1217 + git commit -m "feat(oauth): add scope format validation" 1218 + ``` 1219 + 1220 + --- 1221 + 1222 + ## Task 13: Integrate Scope Validation in Registration 1223 + 1224 + **Files:** 1225 + - Modify: `server/src/handlers/oauth/register.gleam:75-155` 1226 + - Modify: `server/test/oauth/register_test.gleam` 1227 + 1228 + **Step 1: Write the failing test** 1229 + 1230 + Add to `server/test/oauth/register_test.gleam`: 1231 + 1232 + ```gleam 1233 + pub fn register_valid_scope_test() { 1234 + let assert Ok(conn) = sqlight.open(":memory:") 1235 + let assert Ok(_) = tables.create_oauth_client_table(conn) 1236 + 1237 + let body = 1238 + json.object([ 1239 + #("client_name", json.string("Test Client")), 1240 + #( 1241 + "redirect_uris", 1242 + json.array([json.string("https://example.com/callback")], fn(x) { x }), 1243 + ), 1244 + #("scope", json.string("atproto repo:app.bsky.feed.post account:email")), 1245 + ]) 1246 + |> json.to_string 1247 + 1248 + let req = 1249 + simulate.request(http.Post, "/oauth/register") 1250 + |> simulate.string_body(body) 1251 + |> simulate.header("content-type", "application/json") 1252 + 1253 + let response = register.handle(req, conn) 1254 + 1255 + response.status |> should.equal(201) 1256 + } 1257 + 1258 + pub fn register_invalid_scope_test() { 1259 + let assert Ok(conn) = sqlight.open(":memory:") 1260 + let assert Ok(_) = tables.create_oauth_client_table(conn) 1261 + 1262 + let body = 1263 + json.object([ 1264 + #("client_name", json.string("Test Client")), 1265 + #( 1266 + "redirect_uris", 1267 + json.array([json.string("https://example.com/callback")], fn(x) { x }), 1268 + ), 1269 + #("scope", json.string("atproto invalid:::")), 1270 + ]) 1271 + |> json.to_string 1272 + 1273 + let req = 1274 + simulate.request(http.Post, "/oauth/register") 1275 + |> simulate.string_body(body) 1276 + |> simulate.header("content-type", "application/json") 1277 + 1278 + let response = register.handle(req, conn) 1279 + 1280 + response.status |> should.equal(400) 1281 + case response.body { 1282 + wisp.Text(body) -> { 1283 + body |> string.contains("invalid_scope") |> should.be_true 1284 + } 1285 + _ -> should.fail() 1286 + } 1287 + } 1288 + ``` 1289 + 1290 + **Step 2: Run test to verify it fails** 1291 + 1292 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1293 + Expected: FAIL - invalid scope test fails (currently accepts any scope) 1294 + 1295 + **Step 3: Write minimal implementation** 1296 + 1297 + Update `server/src/handlers/oauth/register.gleam`. Add import: 1298 + 1299 + ```gleam 1300 + import lib/oauth/scopes/validator as scope_validator 1301 + ``` 1302 + 1303 + Update `parse_and_register` function around line 91 (after redirect_uris validation): 1304 + 1305 + ```gleam 1306 + // Validate scope format if provided 1307 + use _ <- result.try(case req.scope { 1308 + Some(scope_str) -> 1309 + scope_validator.validate_scope_format(scope_str) 1310 + |> result.map(fn(_) { Nil }) 1311 + |> result.map_error(fn(e) { 1312 + #(400, "invalid_scope", error.error_description(e)) 1313 + }) 1314 + None -> Ok(Nil) 1315 + }) 1316 + ``` 1317 + 1318 + Also add import for error: 1319 + 1320 + ```gleam 1321 + import lib/oauth/types/error 1322 + ``` 1323 + 1324 + **Step 4: Run test to verify it passes** 1325 + 1326 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1327 + Expected: PASS 1328 + 1329 + **Step 5: Commit** 1330 + 1331 + ```bash 1332 + git add server/src/handlers/oauth/register.gleam server/test/oauth/register_test.gleam 1333 + git commit -m "feat(oauth): validate scopes during client registration" 1334 + ``` 1335 + 1336 + --- 1337 + 1338 + ## Task 14: Integrate Scope Validation in Authorization 1339 + 1340 + **Files:** 1341 + - Modify: `server/src/handlers/oauth/authorize.gleam:192-226` 1342 + - Modify: `server/test/oauth/authorize_test.gleam` 1343 + 1344 + **Step 1: Write the failing test** 1345 + 1346 + Add to `server/test/oauth/authorize_test.gleam` (create if doesn't exist appropriate test): 1347 + 1348 + ```gleam 1349 + pub fn authorize_invalid_scope_test() { 1350 + // Setup test database and client 1351 + let assert Ok(conn) = sqlight.open(":memory:") 1352 + // ... setup client ... 1353 + 1354 + // Test with invalid scope 1355 + let query = "response_type=code&client_id=test&redirect_uri=https://example.com/callback&scope=invalid:::" 1356 + 1357 + // Expect error response for invalid scope format 1358 + // The exact test depends on your existing test patterns 1359 + } 1360 + ``` 1361 + 1362 + **Step 2: Run test to verify it fails** 1363 + 1364 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1365 + Expected: FAIL 1366 + 1367 + **Step 3: Write minimal implementation** 1368 + 1369 + Update `server/src/handlers/oauth/authorize.gleam`. Add import: 1370 + 1371 + ```gleam 1372 + import lib/oauth/scopes/validator as scope_validator 1373 + ``` 1374 + 1375 + Update `validate_authorization_request` function around line 192. Add scope validation after PKCE validation: 1376 + 1377 + ```gleam 1378 + fn validate_authorization_request( 1379 + req: AuthorizationRequest, 1380 + client: types.OAuthClient, 1381 + ) -> Result(Nil, String) { 1382 + // ... existing validations ... 1383 + 1384 + // Validate scope format if provided 1385 + use _ <- result.try(case req.scope { 1386 + Some(scope_str) -> 1387 + scope_validator.validate_scope_format(scope_str) 1388 + |> result.map(fn(_) { Nil }) 1389 + |> result.map_error(fn(e) { error.error_description(e) }) 1390 + None -> Ok(Nil) 1391 + }) 1392 + 1393 + Ok(Nil) 1394 + } 1395 + ``` 1396 + 1397 + **Step 4: Run test to verify it passes** 1398 + 1399 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1400 + Expected: PASS 1401 + 1402 + **Step 5: Commit** 1403 + 1404 + ```bash 1405 + git add server/src/handlers/oauth/authorize.gleam server/test/oauth/authorize_test.gleam 1406 + git commit -m "feat(oauth): validate scopes during authorization" 1407 + ``` 1408 + 1409 + --- 1410 + 1411 + ## Task 15: Integrate Scope Validation in Token Refresh 1412 + 1413 + **Files:** 1414 + - Modify: `server/src/handlers/oauth/token.gleam:193-332` 1415 + - Modify: `server/test/oauth/token_test.gleam` 1416 + 1417 + **Step 1: Write the failing test** 1418 + 1419 + Add to `server/test/oauth/token_test.gleam`: 1420 + 1421 + ```gleam 1422 + pub fn refresh_token_invalid_scope_test() { 1423 + // Setup: Create client, authorization code, tokens 1424 + // ... 1425 + 1426 + // Try to refresh with invalid scope format 1427 + let body = "grant_type=refresh_token&refresh_token=xxx&client_id=xxx&scope=invalid:::" 1428 + 1429 + // Expect 400 with invalid_scope error 1430 + } 1431 + ``` 1432 + 1433 + **Step 2: Run test to verify it fails** 1434 + 1435 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1436 + Expected: FAIL 1437 + 1438 + **Step 3: Write minimal implementation** 1439 + 1440 + Update `server/src/handlers/oauth/token.gleam`. Add import: 1441 + 1442 + ```gleam 1443 + import lib/oauth/scopes/validator as scope_validator 1444 + import lib/oauth/types/error 1445 + ``` 1446 + 1447 + In `handle_refresh_token` around line 248 (where scope is determined), add validation: 1448 + 1449 + ```gleam 1450 + // Validate scope format if new scope requested 1451 + use _ <- result.try(case requested_scope { 1452 + Some(scope_str) -> 1453 + case scope_validator.validate_scope_format(scope_str) { 1454 + Ok(_) -> Ok(Nil) 1455 + Error(e) -> 1456 + Error(error_response(400, "invalid_scope", error.error_description(e))) 1457 + } 1458 + None -> Ok(Nil) 1459 + }) 1460 + ``` 1461 + 1462 + **Step 4: Run test to verify it passes** 1463 + 1464 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1465 + Expected: PASS 1466 + 1467 + **Step 5: Commit** 1468 + 1469 + ```bash 1470 + git add server/src/handlers/oauth/token.gleam server/test/oauth/token_test.gleam 1471 + git commit -m "feat(oauth): validate scopes during token refresh" 1472 + ``` 1473 + 1474 + --- 1475 + 1476 + ## Task 16: Final Integration Test 1477 + 1478 + **Files:** 1479 + - Create: `server/test/oauth/scopes/integration_test.gleam` 1480 + 1481 + **Step 1: Write comprehensive integration test** 1482 + 1483 + Create `server/test/oauth/scopes/integration_test.gleam`: 1484 + 1485 + ```gleam 1486 + /// Integration tests for OAuth scope validation across all endpoints 1487 + 1488 + import database/schema/tables 1489 + import gleam/http 1490 + import gleam/json 1491 + import gleam/string 1492 + import gleeunit/should 1493 + import handlers/oauth/register 1494 + import sqlight 1495 + import wisp 1496 + import wisp/simulate 1497 + 1498 + /// Test that all ATProto scope types are accepted 1499 + pub fn all_atproto_scopes_accepted_test() { 1500 + let assert Ok(conn) = sqlight.open(":memory:") 1501 + let assert Ok(_) = tables.create_oauth_client_table(conn) 1502 + 1503 + let all_scopes = 1504 + "atproto transition:generic transition:email transition:chat.bsky " 1505 + <> "account:email account:repo?action=manage account:status " 1506 + <> "identity:handle identity:* " 1507 + <> "repo:* repo:app.bsky.feed.post?action=create " 1508 + <> "blob:*/* blob:image/* blob:image/png " 1509 + <> "rpc:app.bsky.feed.getFeed?aud=did:web:bsky.app " 1510 + <> "include:app.bsky.feed include:chat.bsky?aud=did:web:bsky.chat" 1511 + 1512 + let body = 1513 + json.object([ 1514 + #("client_name", json.string("Full Scope Test")), 1515 + #( 1516 + "redirect_uris", 1517 + json.array([json.string("https://example.com/callback")], fn(x) { x }), 1518 + ), 1519 + #("scope", json.string(all_scopes)), 1520 + ]) 1521 + |> json.to_string 1522 + 1523 + let req = 1524 + simulate.request(http.Post, "/oauth/register") 1525 + |> simulate.string_body(body) 1526 + |> simulate.header("content-type", "application/json") 1527 + 1528 + let response = register.handle(req, conn) 1529 + 1530 + response.status |> should.equal(201) 1531 + } 1532 + ``` 1533 + 1534 + **Step 2: Run test to verify it passes** 1535 + 1536 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1537 + Expected: PASS 1538 + 1539 + **Step 3: Commit** 1540 + 1541 + ```bash 1542 + git add server/test/oauth/scopes/integration_test.gleam 1543 + git commit -m "test(oauth): add comprehensive scope integration test" 1544 + ``` 1545 + 1546 + --- 1547 + 1548 + ## Summary 1549 + 1550 + This plan implements full ATProto OAuth scope parsing and validation: 1551 + 1552 + 1. **Tasks 1-3**: Create type definitions and error types 1553 + 2. **Tasks 4-10**: Build parser for each scope type (static, account, identity, repo, blob, rpc, include) 1554 + 3. **Task 11**: Add `parse_scopes` for space-separated strings 1555 + 4. **Task 12**: Create validator module bridging parser to OAuth errors 1556 + 5. **Tasks 13-15**: Integrate validation at registration, authorization, and token refresh 1557 + 6. **Task 16**: Comprehensive integration test 1558 + 1559 + Each task follows TDD: write failing test → implement → verify → commit.