Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql
67
fork

Configure Feed

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

docs: add GraphQL security hardening implementation plan

+2599
+2599
dev-docs/plans/2025-12-18-graphql-security-hardening.md
··· 1 + # GraphQL Security Hardening Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Implement OWASP-recommended GraphQL security protections across swell (executor) and lexicon_graphql (schema generation) libraries. 6 + 7 + **Architecture:** Security validations run pre-execution in swell (depth, cost, introspection, batching). Custom scalars and constraint validation live in lexicon_graphql. Rate limiting stays in quickslice server using ETS-backed sliding window counters. 8 + 9 + **Tech Stack:** Gleam, OTP (process timeouts, ETS), swell GraphQL executor, lexicon_graphql schema generator 10 + 11 + --- 12 + 13 + ## Part 1: Swell Security Configuration 14 + 15 + ### Task 1: Add SecurityConfig Type 16 + 17 + **Files:** 18 + - Create: `/Users/chadmiller/code/swell/src/swell/security.gleam` 19 + - Test: `/Users/chadmiller/code/swell/test/security_test.gleam` 20 + 21 + **Step 1: Write the failing test** 22 + 23 + ```gleam 24 + // test/security_test.gleam 25 + import gleeunit/should 26 + import swell/security 27 + 28 + pub fn default_security_config_test() { 29 + let config = security.default_config() 30 + 31 + config.max_depth |> should.equal(10) 32 + config.max_cost |> should.equal(10_000) 33 + config.default_list_cost |> should.equal(25) 34 + config.timeout_ms |> should.equal(30_000) 35 + } 36 + 37 + pub fn production_security_config_test() { 38 + let config = security.production_config() 39 + 40 + config.max_depth |> should.equal(10) 41 + config.max_cost |> should.equal(5000) 42 + config.timeout_ms |> should.equal(15_000) 43 + } 44 + ``` 45 + 46 + **Step 2: Run test to verify it fails** 47 + 48 + Run: `cd /Users/chadmiller/code/swell && gleam test` 49 + Expected: FAIL with "module swell/security not found" 50 + 51 + **Step 3: Write minimal implementation** 52 + 53 + ```gleam 54 + // src/swell/security.gleam 55 + import gleam/option.{type Option, None, Some} 56 + import swell/schema 57 + 58 + /// Security configuration for GraphQL execution 59 + pub type SecurityConfig { 60 + SecurityConfig( 61 + /// Maximum query nesting depth (default: 10) 62 + max_depth: Int, 63 + /// Maximum query cost before rejection (default: 10_000) 64 + max_cost: Int, 65 + /// Default cost multiplier for list fields without explicit limit (default: 25) 66 + default_list_cost: Int, 67 + /// Execution timeout in milliseconds (default: 30_000) 68 + timeout_ms: Int, 69 + /// Callback to determine if introspection is allowed (default: always true) 70 + allow_introspection: fn(schema.Context) -> Bool, 71 + /// Batching policy (default: Limited(10)) 72 + batch_policy: BatchPolicy, 73 + ) 74 + } 75 + 76 + /// Batching policy for multi-operation requests 77 + pub type BatchPolicy { 78 + /// Reject all batched requests 79 + Disabled 80 + /// Allow up to N operations per request 81 + Limited(max: Int) 82 + } 83 + 84 + /// Security error types 85 + pub type SecurityError { 86 + QueryTooDeep(depth: Int, max: Int, path: List(String)) 87 + QueryTooExpensive(cost: Int, max: Int) 88 + ExecutionTimeout(timeout_ms: Int) 89 + IntrospectionDisabled 90 + BatchingDisabled 91 + BatchTooLarge(count: Int, max: Int) 92 + } 93 + 94 + /// Default security config for development (permissive) 95 + pub fn default_config() -> SecurityConfig { 96 + SecurityConfig( 97 + max_depth: 10, 98 + max_cost: 10_000, 99 + default_list_cost: 25, 100 + timeout_ms: 30_000, 101 + allow_introspection: fn(_) { True }, 102 + batch_policy: Limited(10), 103 + ) 104 + } 105 + 106 + /// Production security config (stricter) 107 + pub fn production_config() -> SecurityConfig { 108 + SecurityConfig( 109 + max_depth: 10, 110 + max_cost: 5000, 111 + default_list_cost: 25, 112 + timeout_ms: 15_000, 113 + allow_introspection: fn(_) { False }, 114 + batch_policy: Limited(5), 115 + ) 116 + } 117 + 118 + /// Convert security error to human-readable message 119 + pub fn error_message(error: SecurityError) -> String { 120 + case error { 121 + QueryTooDeep(depth, max, _path) -> 122 + "Query depth " <> int_to_string(depth) <> " exceeds maximum " <> int_to_string(max) 123 + QueryTooExpensive(cost, max) -> 124 + "Query cost " <> int_to_string(cost) <> " exceeds maximum " <> int_to_string(max) 125 + ExecutionTimeout(timeout) -> 126 + "Query execution timed out after " <> int_to_string(timeout) <> "ms" 127 + IntrospectionDisabled -> 128 + "Introspection is disabled" 129 + BatchingDisabled -> 130 + "Batched queries are not allowed" 131 + BatchTooLarge(count, max) -> 132 + "Batch contains " <> int_to_string(count) <> " operations, maximum is " <> int_to_string(max) 133 + } 134 + } 135 + 136 + /// Convert security error to extension code for GraphQL response 137 + pub fn error_code(error: SecurityError) -> String { 138 + case error { 139 + QueryTooDeep(_, _, _) -> "QUERY_TOO_DEEP" 140 + QueryTooExpensive(_, _) -> "QUERY_TOO_EXPENSIVE" 141 + ExecutionTimeout(_) -> "EXECUTION_TIMEOUT" 142 + IntrospectionDisabled -> "INTROSPECTION_DISABLED" 143 + BatchingDisabled -> "BATCHING_DISABLED" 144 + BatchTooLarge(_, _) -> "BATCH_TOO_LARGE" 145 + } 146 + } 147 + 148 + fn int_to_string(n: Int) -> String { 149 + case n { 150 + 0 -> "0" 151 + _ -> do_int_to_string(n, "") 152 + } 153 + } 154 + 155 + fn do_int_to_string(n: Int, acc: String) -> String { 156 + case n { 157 + 0 -> acc 158 + _ -> { 159 + let digit = n % 10 160 + let char = case digit { 161 + 0 -> "0" 162 + 1 -> "1" 163 + 2 -> "2" 164 + 3 -> "3" 165 + 4 -> "4" 166 + 5 -> "5" 167 + 6 -> "6" 168 + 7 -> "7" 169 + 8 -> "8" 170 + _ -> "9" 171 + } 172 + do_int_to_string(n / 10, char <> acc) 173 + } 174 + } 175 + } 176 + ``` 177 + 178 + **Step 4: Run test to verify it passes** 179 + 180 + Run: `cd /Users/chadmiller/code/swell && gleam test` 181 + Expected: PASS 182 + 183 + **Step 5: Commit** 184 + 185 + ```bash 186 + cd /Users/chadmiller/code/swell 187 + git add src/swell/security.gleam test/security_test.gleam 188 + git commit -m "feat(security): add SecurityConfig type and default configs" 189 + ``` 190 + 191 + --- 192 + 193 + ### Task 2: Implement Query Depth Validation 194 + 195 + **Files:** 196 + - Modify: `/Users/chadmiller/code/swell/src/swell/security.gleam` 197 + - Test: `/Users/chadmiller/code/swell/test/security_test.gleam` 198 + 199 + **Step 1: Write the failing test** 200 + 201 + ```gleam 202 + // Add to test/security_test.gleam 203 + import swell/parser 204 + 205 + pub fn validate_depth_shallow_query_test() { 206 + let query = "{ user { name } }" 207 + let assert Ok(doc) = parser.parse(query) 208 + let config = security.default_config() 209 + 210 + security.validate_depth(doc, config) 211 + |> should.be_ok() 212 + } 213 + 214 + pub fn validate_depth_deep_query_fails_test() { 215 + // Query with depth 4: a -> b -> c -> d -> e 216 + let query = "{ a { b { c { d { e } } } } }" 217 + let assert Ok(doc) = parser.parse(query) 218 + let config = security.SecurityConfig(..security.default_config(), max_depth: 3) 219 + 220 + case security.validate_depth(doc, config) { 221 + Error(security.QueryTooDeep(depth, max, _)) -> { 222 + depth |> should.equal(5) 223 + max |> should.equal(3) 224 + } 225 + _ -> should.fail() 226 + } 227 + } 228 + 229 + pub fn validate_depth_with_fragments_test() { 230 + let query = " 231 + query { user { ...UserFields } } 232 + fragment UserFields on User { posts { title } } 233 + " 234 + let assert Ok(doc) = parser.parse(query) 235 + let config = security.SecurityConfig(..security.default_config(), max_depth: 3) 236 + 237 + // user(1) -> posts(2) -> title(3) = depth 3, should pass 238 + security.validate_depth(doc, config) 239 + |> should.be_ok() 240 + } 241 + ``` 242 + 243 + **Step 2: Run test to verify it fails** 244 + 245 + Run: `cd /Users/chadmiller/code/swell && gleam test` 246 + Expected: FAIL with "validate_depth not found" 247 + 248 + **Step 3: Write minimal implementation** 249 + 250 + ```gleam 251 + // Add to src/swell/security.gleam 252 + import gleam/dict.{type Dict} 253 + import gleam/list 254 + import gleam/result 255 + import swell/parser 256 + 257 + /// Validate query depth against config limit 258 + pub fn validate_depth( 259 + document: parser.Document, 260 + config: SecurityConfig, 261 + ) -> Result(Nil, SecurityError) { 262 + let parser.Document(operations) = document 263 + 264 + // Build fragments dictionary 265 + let fragments = build_fragments_dict(operations) 266 + 267 + // Check each executable operation 268 + list.try_each(operations, fn(op) { 269 + case op { 270 + parser.FragmentDefinition(_, _, _) -> Ok(Nil) 271 + _ -> validate_operation_depth(op, config.max_depth, fragments) 272 + } 273 + }) 274 + } 275 + 276 + fn build_fragments_dict( 277 + operations: List(parser.Operation), 278 + ) -> Dict(String, parser.Operation) { 279 + operations 280 + |> list.filter_map(fn(op) { 281 + case op { 282 + parser.FragmentDefinition(name, _, _) -> Ok(#(name, op)) 283 + _ -> Error(Nil) 284 + } 285 + }) 286 + |> dict.from_list() 287 + } 288 + 289 + fn validate_operation_depth( 290 + operation: parser.Operation, 291 + max_depth: Int, 292 + fragments: Dict(String, parser.Operation), 293 + ) -> Result(Nil, SecurityError) { 294 + let selection_set = case operation { 295 + parser.Query(ss) -> ss 296 + parser.NamedQuery(_, _, ss) -> ss 297 + parser.Mutation(ss) -> ss 298 + parser.NamedMutation(_, _, ss) -> ss 299 + parser.Subscription(ss) -> ss 300 + parser.NamedSubscription(_, _, ss) -> ss 301 + parser.FragmentDefinition(_, _, ss) -> ss 302 + } 303 + 304 + validate_selection_set_depth(selection_set, 1, max_depth, fragments, []) 305 + } 306 + 307 + fn validate_selection_set_depth( 308 + selection_set: parser.SelectionSet, 309 + current_depth: Int, 310 + max_depth: Int, 311 + fragments: Dict(String, parser.Operation), 312 + path: List(String), 313 + ) -> Result(Nil, SecurityError) { 314 + case current_depth > max_depth { 315 + True -> Error(QueryTooDeep(current_depth, max_depth, list.reverse(path))) 316 + False -> { 317 + let parser.SelectionSet(selections) = selection_set 318 + list.try_each(selections, fn(selection) { 319 + validate_selection_depth(selection, current_depth, max_depth, fragments, path) 320 + }) 321 + } 322 + } 323 + } 324 + 325 + fn validate_selection_depth( 326 + selection: parser.Selection, 327 + current_depth: Int, 328 + max_depth: Int, 329 + fragments: Dict(String, parser.Operation), 330 + path: List(String), 331 + ) -> Result(Nil, SecurityError) { 332 + case selection { 333 + parser.Field(name, _, _, nested) -> { 334 + case nested { 335 + [] -> Ok(Nil) 336 + _ -> { 337 + let nested_set = parser.SelectionSet(nested) 338 + validate_selection_set_depth( 339 + nested_set, 340 + current_depth + 1, 341 + max_depth, 342 + fragments, 343 + [name, ..path], 344 + ) 345 + } 346 + } 347 + } 348 + parser.FragmentSpread(name) -> { 349 + case dict.get(fragments, name) { 350 + Ok(parser.FragmentDefinition(_, _, ss)) -> 351 + validate_selection_set_depth(ss, current_depth, max_depth, fragments, path) 352 + _ -> Ok(Nil) 353 + } 354 + } 355 + parser.InlineFragment(_, nested) -> { 356 + let nested_set = parser.SelectionSet(nested) 357 + validate_selection_set_depth(nested_set, current_depth, max_depth, fragments, path) 358 + } 359 + } 360 + } 361 + ``` 362 + 363 + **Step 4: Run test to verify it passes** 364 + 365 + Run: `cd /Users/chadmiller/code/swell && gleam test` 366 + Expected: PASS 367 + 368 + **Step 5: Commit** 369 + 370 + ```bash 371 + cd /Users/chadmiller/code/swell 372 + git add src/swell/security.gleam test/security_test.gleam 373 + git commit -m "feat(security): implement query depth validation" 374 + ``` 375 + 376 + --- 377 + 378 + ### Task 3: Implement Query Cost Analysis 379 + 380 + **Files:** 381 + - Modify: `/Users/chadmiller/code/swell/src/swell/security.gleam` 382 + - Test: `/Users/chadmiller/code/swell/test/security_test.gleam` 383 + 384 + **Step 1: Write the failing test** 385 + 386 + ```gleam 387 + // Add to test/security_test.gleam 388 + 389 + pub fn calculate_cost_simple_query_test() { 390 + let query = "{ user { name email } }" 391 + let assert Ok(doc) = parser.parse(query) 392 + let config = security.default_config() 393 + 394 + case security.calculate_cost(doc, config) { 395 + Ok(cost) -> cost |> should.equal(1) // user = 1, scalars = 0 396 + Error(_) -> should.fail() 397 + } 398 + } 399 + 400 + pub fn calculate_cost_nested_lists_test() { 401 + // users(first:100) { posts(first:10) { comments(first:5) { text } } } 402 + let query = "{ users(first: 100) { posts(first: 10) { comments(first: 5) { text } } } }" 403 + let assert Ok(doc) = parser.parse(query) 404 + let config = security.default_config() 405 + 406 + case security.calculate_cost(doc, config) { 407 + // users: 100, posts: 100*10=1000, comments: 1000*5=5000, total: 6100 408 + Ok(cost) -> cost |> should.equal(6100) 409 + Error(_) -> should.fail() 410 + } 411 + } 412 + 413 + pub fn validate_cost_rejects_expensive_query_test() { 414 + let query = "{ users(first: 1000) { posts(first: 100) { title } } }" 415 + let assert Ok(doc) = parser.parse(query) 416 + let config = security.SecurityConfig(..security.default_config(), max_cost: 10_000) 417 + 418 + case security.validate_cost(doc, config) { 419 + // users: 1000, posts: 1000*100=100000, total: 101000 > 10000 420 + Error(security.QueryTooExpensive(cost, max)) -> { 421 + cost |> should.equal(101_000) 422 + max |> should.equal(10_000) 423 + } 424 + _ -> should.fail() 425 + } 426 + } 427 + ``` 428 + 429 + **Step 2: Run test to verify it fails** 430 + 431 + Run: `cd /Users/chadmiller/code/swell && gleam test` 432 + Expected: FAIL with "calculate_cost not found" 433 + 434 + **Step 3: Write minimal implementation** 435 + 436 + ```gleam 437 + // Add to src/swell/security.gleam 438 + import gleam/int 439 + 440 + /// Calculate query cost 441 + pub fn calculate_cost( 442 + document: parser.Document, 443 + config: SecurityConfig, 444 + ) -> Result(Int, SecurityError) { 445 + let parser.Document(operations) = document 446 + let fragments = build_fragments_dict(operations) 447 + 448 + // Sum cost of all executable operations 449 + list.try_fold(operations, 0, fn(total, op) { 450 + case op { 451 + parser.FragmentDefinition(_, _, _) -> Ok(total) 452 + _ -> { 453 + case calculate_operation_cost(op, config, fragments, 1) { 454 + Ok(cost) -> Ok(total + cost) 455 + Error(e) -> Error(e) 456 + } 457 + } 458 + } 459 + }) 460 + } 461 + 462 + /// Validate query cost against config limit 463 + pub fn validate_cost( 464 + document: parser.Document, 465 + config: SecurityConfig, 466 + ) -> Result(Nil, SecurityError) { 467 + case calculate_cost(document, config) { 468 + Ok(cost) if cost > config.max_cost -> 469 + Error(QueryTooExpensive(cost, config.max_cost)) 470 + Ok(_) -> Ok(Nil) 471 + Error(e) -> Error(e) 472 + } 473 + } 474 + 475 + fn calculate_operation_cost( 476 + operation: parser.Operation, 477 + config: SecurityConfig, 478 + fragments: Dict(String, parser.Operation), 479 + multiplier: Int, 480 + ) -> Result(Int, SecurityError) { 481 + let selection_set = case operation { 482 + parser.Query(ss) -> ss 483 + parser.NamedQuery(_, _, ss) -> ss 484 + parser.Mutation(ss) -> ss 485 + parser.NamedMutation(_, _, ss) -> ss 486 + parser.Subscription(ss) -> ss 487 + parser.NamedSubscription(_, _, ss) -> ss 488 + parser.FragmentDefinition(_, _, ss) -> ss 489 + } 490 + 491 + calculate_selection_set_cost(selection_set, config, fragments, multiplier) 492 + } 493 + 494 + fn calculate_selection_set_cost( 495 + selection_set: parser.SelectionSet, 496 + config: SecurityConfig, 497 + fragments: Dict(String, parser.Operation), 498 + multiplier: Int, 499 + ) -> Result(Int, SecurityError) { 500 + let parser.SelectionSet(selections) = selection_set 501 + 502 + list.try_fold(selections, 0, fn(total, selection) { 503 + case calculate_selection_cost(selection, config, fragments, multiplier) { 504 + Ok(cost) -> Ok(total + cost) 505 + Error(e) -> Error(e) 506 + } 507 + }) 508 + } 509 + 510 + fn calculate_selection_cost( 511 + selection: parser.Selection, 512 + config: SecurityConfig, 513 + fragments: Dict(String, parser.Operation), 514 + multiplier: Int, 515 + ) -> Result(Int, SecurityError) { 516 + case selection { 517 + parser.Field(_, _, arguments, nested) -> { 518 + case nested { 519 + [] -> Ok(0) // Scalar field, no cost 520 + _ -> { 521 + // Object/list field - calculate cost based on limit argument 522 + let limit = get_limit_from_arguments(arguments, config.default_list_cost) 523 + let field_cost = multiplier * limit 524 + let nested_set = parser.SelectionSet(nested) 525 + case calculate_selection_set_cost(nested_set, config, fragments, field_cost) { 526 + Ok(nested_cost) -> Ok(field_cost + nested_cost) 527 + Error(e) -> Error(e) 528 + } 529 + } 530 + } 531 + } 532 + parser.FragmentSpread(name) -> { 533 + case dict.get(fragments, name) { 534 + Ok(parser.FragmentDefinition(_, _, ss)) -> 535 + calculate_selection_set_cost(ss, config, fragments, multiplier) 536 + _ -> Ok(0) 537 + } 538 + } 539 + parser.InlineFragment(_, nested) -> { 540 + let nested_set = parser.SelectionSet(nested) 541 + calculate_selection_set_cost(nested_set, config, fragments, multiplier) 542 + } 543 + } 544 + } 545 + 546 + fn get_limit_from_arguments( 547 + arguments: List(parser.Argument), 548 + default: Int, 549 + ) -> Int { 550 + // Look for first, last, limit, or take arguments 551 + list.find_map(arguments, fn(arg) { 552 + case arg { 553 + parser.Argument("first", parser.IntValue(val)) -> parse_int(val) 554 + parser.Argument("last", parser.IntValue(val)) -> parse_int(val) 555 + parser.Argument("limit", parser.IntValue(val)) -> parse_int(val) 556 + parser.Argument("take", parser.IntValue(val)) -> parse_int(val) 557 + _ -> Error(Nil) 558 + } 559 + }) 560 + |> result.unwrap(default) 561 + } 562 + 563 + fn parse_int(s: String) -> Result(Int, Nil) { 564 + int.parse(s) 565 + } 566 + ``` 567 + 568 + **Step 4: Run test to verify it passes** 569 + 570 + Run: `cd /Users/chadmiller/code/swell && gleam test` 571 + Expected: PASS 572 + 573 + **Step 5: Commit** 574 + 575 + ```bash 576 + cd /Users/chadmiller/code/swell 577 + git add src/swell/security.gleam test/security_test.gleam 578 + git commit -m "feat(security): implement query cost analysis" 579 + ``` 580 + 581 + --- 582 + 583 + ### Task 4: Implement Introspection Control 584 + 585 + **Files:** 586 + - Modify: `/Users/chadmiller/code/swell/src/swell/security.gleam` 587 + - Test: `/Users/chadmiller/code/swell/test/security_test.gleam` 588 + 589 + **Step 1: Write the failing test** 590 + 591 + ```gleam 592 + // Add to test/security_test.gleam 593 + import swell/schema 594 + 595 + pub fn validate_introspection_allowed_test() { 596 + let query = "{ __schema { types { name } } }" 597 + let assert Ok(doc) = parser.parse(query) 598 + let config = security.default_config() // allows introspection by default 599 + let ctx = schema.context(option.None) 600 + 601 + security.validate_introspection(doc, ctx, config) 602 + |> should.be_ok() 603 + } 604 + 605 + pub fn validate_introspection_blocked_test() { 606 + let query = "{ __schema { types { name } } }" 607 + let assert Ok(doc) = parser.parse(query) 608 + let config = security.SecurityConfig( 609 + ..security.default_config(), 610 + allow_introspection: fn(_) { False }, 611 + ) 612 + let ctx = schema.context(option.None) 613 + 614 + case security.validate_introspection(doc, ctx, config) { 615 + Error(security.IntrospectionDisabled) -> Nil 616 + _ -> should.fail() 617 + } 618 + } 619 + 620 + pub fn validate_introspection_typename_allowed_test() { 621 + // __typename should always be allowed 622 + let query = "{ user { __typename name } }" 623 + let assert Ok(doc) = parser.parse(query) 624 + let config = security.SecurityConfig( 625 + ..security.default_config(), 626 + allow_introspection: fn(_) { False }, 627 + ) 628 + let ctx = schema.context(option.None) 629 + 630 + security.validate_introspection(doc, ctx, config) 631 + |> should.be_ok() 632 + } 633 + ``` 634 + 635 + **Step 2: Run test to verify it fails** 636 + 637 + Run: `cd /Users/chadmiller/code/swell && gleam test` 638 + Expected: FAIL with "validate_introspection not found" 639 + 640 + **Step 3: Write minimal implementation** 641 + 642 + ```gleam 643 + // Add to src/swell/security.gleam 644 + 645 + /// Validate introspection access 646 + pub fn validate_introspection( 647 + document: parser.Document, 648 + ctx: schema.Context, 649 + config: SecurityConfig, 650 + ) -> Result(Nil, SecurityError) { 651 + case has_introspection_fields(document) { 652 + False -> Ok(Nil) 653 + True -> { 654 + case config.allow_introspection(ctx) { 655 + True -> Ok(Nil) 656 + False -> Error(IntrospectionDisabled) 657 + } 658 + } 659 + } 660 + } 661 + 662 + fn has_introspection_fields(document: parser.Document) -> Bool { 663 + let parser.Document(operations) = document 664 + list.any(operations, fn(op) { 665 + case op { 666 + parser.Query(ss) -> selection_set_has_introspection(ss) 667 + parser.NamedQuery(_, _, ss) -> selection_set_has_introspection(ss) 668 + parser.Mutation(ss) -> selection_set_has_introspection(ss) 669 + parser.NamedMutation(_, _, ss) -> selection_set_has_introspection(ss) 670 + parser.Subscription(ss) -> selection_set_has_introspection(ss) 671 + parser.NamedSubscription(_, _, ss) -> selection_set_has_introspection(ss) 672 + parser.FragmentDefinition(_, _, _) -> False 673 + } 674 + }) 675 + } 676 + 677 + fn selection_set_has_introspection(selection_set: parser.SelectionSet) -> Bool { 678 + let parser.SelectionSet(selections) = selection_set 679 + list.any(selections, fn(selection) { 680 + case selection { 681 + parser.Field(name, _, _, _) -> 682 + // __schema and __type are introspection queries 683 + // __typename is allowed everywhere 684 + name == "__schema" || name == "__type" 685 + parser.FragmentSpread(_) -> False 686 + parser.InlineFragment(_, nested) -> 687 + selection_set_has_introspection(parser.SelectionSet(nested)) 688 + } 689 + }) 690 + } 691 + ``` 692 + 693 + **Step 4: Run test to verify it passes** 694 + 695 + Run: `cd /Users/chadmiller/code/swell && gleam test` 696 + Expected: PASS 697 + 698 + **Step 5: Commit** 699 + 700 + ```bash 701 + cd /Users/chadmiller/code/swell 702 + git add src/swell/security.gleam test/security_test.gleam 703 + git commit -m "feat(security): implement introspection control" 704 + ``` 705 + 706 + --- 707 + 708 + ### Task 5: Implement Batch Validation 709 + 710 + **Files:** 711 + - Modify: `/Users/chadmiller/code/swell/src/swell/security.gleam` 712 + - Test: `/Users/chadmiller/code/swell/test/security_test.gleam` 713 + 714 + **Step 1: Write the failing test** 715 + 716 + ```gleam 717 + // Add to test/security_test.gleam 718 + 719 + pub fn validate_batch_within_limit_test() { 720 + let operations = [ 721 + parser.Query(parser.SelectionSet([])), 722 + parser.Query(parser.SelectionSet([])), 723 + ] 724 + let config = security.SecurityConfig(..security.default_config(), batch_policy: security.Limited(10)) 725 + 726 + security.validate_batch(operations, config) 727 + |> should.be_ok() 728 + } 729 + 730 + pub fn validate_batch_exceeds_limit_test() { 731 + let operations = list.repeat(parser.Query(parser.SelectionSet([])), 15) 732 + let config = security.SecurityConfig(..security.default_config(), batch_policy: security.Limited(10)) 733 + 734 + case security.validate_batch(operations, config) { 735 + Error(security.BatchTooLarge(count, max)) -> { 736 + count |> should.equal(15) 737 + max |> should.equal(10) 738 + } 739 + _ -> should.fail() 740 + } 741 + } 742 + 743 + pub fn validate_batch_disabled_test() { 744 + let operations = [ 745 + parser.Query(parser.SelectionSet([])), 746 + parser.Query(parser.SelectionSet([])), 747 + ] 748 + let config = security.SecurityConfig(..security.default_config(), batch_policy: security.Disabled) 749 + 750 + case security.validate_batch(operations, config) { 751 + Error(security.BatchingDisabled) -> Nil 752 + _ -> should.fail() 753 + } 754 + } 755 + 756 + pub fn validate_batch_single_allowed_when_disabled_test() { 757 + let operations = [parser.Query(parser.SelectionSet([]))] 758 + let config = security.SecurityConfig(..security.default_config(), batch_policy: security.Disabled) 759 + 760 + security.validate_batch(operations, config) 761 + |> should.be_ok() 762 + } 763 + ``` 764 + 765 + **Step 2: Run test to verify it fails** 766 + 767 + Run: `cd /Users/chadmiller/code/swell && gleam test` 768 + Expected: FAIL with "validate_batch not found" 769 + 770 + **Step 3: Write minimal implementation** 771 + 772 + ```gleam 773 + // Add to src/swell/security.gleam 774 + 775 + /// Validate batch size against policy 776 + pub fn validate_batch( 777 + operations: List(parser.Operation), 778 + config: SecurityConfig, 779 + ) -> Result(Nil, SecurityError) { 780 + // Filter out fragment definitions - they don't count as operations 781 + let executable_ops = list.filter(operations, fn(op) { 782 + case op { 783 + parser.FragmentDefinition(_, _, _) -> False 784 + _ -> True 785 + } 786 + }) 787 + 788 + let count = list.length(executable_ops) 789 + 790 + case config.batch_policy { 791 + Disabled -> { 792 + case count <= 1 { 793 + True -> Ok(Nil) 794 + False -> Error(BatchingDisabled) 795 + } 796 + } 797 + Limited(max) -> { 798 + case count <= max { 799 + True -> Ok(Nil) 800 + False -> Error(BatchTooLarge(count, max)) 801 + } 802 + } 803 + } 804 + } 805 + ``` 806 + 807 + **Step 4: Run test to verify it passes** 808 + 809 + Run: `cd /Users/chadmiller/code/swell && gleam test` 810 + Expected: PASS 811 + 812 + **Step 5: Commit** 813 + 814 + ```bash 815 + cd /Users/chadmiller/code/swell 816 + git add src/swell/security.gleam test/security_test.gleam 817 + git commit -m "feat(security): implement batch validation" 818 + ``` 819 + 820 + --- 821 + 822 + ### Task 6: Add Unified validate_all Function 823 + 824 + **Files:** 825 + - Modify: `/Users/chadmiller/code/swell/src/swell/security.gleam` 826 + - Test: `/Users/chadmiller/code/swell/test/security_test.gleam` 827 + 828 + **Step 1: Write the failing test** 829 + 830 + ```gleam 831 + // Add to test/security_test.gleam 832 + 833 + pub fn validate_all_passes_valid_query_test() { 834 + let query = "{ user { name } }" 835 + let assert Ok(doc) = parser.parse(query) 836 + let config = security.default_config() 837 + let ctx = schema.context(option.None) 838 + 839 + security.validate_all(doc, ctx, config) 840 + |> should.be_ok() 841 + } 842 + 843 + pub fn validate_all_fails_on_first_error_test() { 844 + // Too deep query 845 + let query = "{ a { b { c { d { e { f { g { h { i { j { k { l } } } } } } } } } } } }" 846 + let assert Ok(doc) = parser.parse(query) 847 + let config = security.SecurityConfig(..security.default_config(), max_depth: 5) 848 + let ctx = schema.context(option.None) 849 + 850 + case security.validate_all(doc, ctx, config) { 851 + Error(security.QueryTooDeep(_, _, _)) -> Nil 852 + _ -> should.fail() 853 + } 854 + } 855 + ``` 856 + 857 + **Step 2: Run test to verify it fails** 858 + 859 + Run: `cd /Users/chadmiller/code/swell && gleam test` 860 + Expected: FAIL with "validate_all not found" 861 + 862 + **Step 3: Write minimal implementation** 863 + 864 + ```gleam 865 + // Add to src/swell/security.gleam 866 + 867 + /// Run all security validations 868 + pub fn validate_all( 869 + document: parser.Document, 870 + ctx: schema.Context, 871 + config: SecurityConfig, 872 + ) -> Result(Nil, SecurityError) { 873 + let parser.Document(operations) = document 874 + 875 + // Validate batch size first (cheapest check) 876 + use _ <- result.try(validate_batch(operations, config)) 877 + 878 + // Validate introspection access 879 + use _ <- result.try(validate_introspection(document, ctx, config)) 880 + 881 + // Validate depth (before cost, since deep queries are often expensive) 882 + use _ <- result.try(validate_depth(document, config)) 883 + 884 + // Validate cost 885 + use _ <- result.try(validate_cost(document, config)) 886 + 887 + Ok(Nil) 888 + } 889 + ``` 890 + 891 + **Step 4: Run test to verify it passes** 892 + 893 + Run: `cd /Users/chadmiller/code/swell && gleam test` 894 + Expected: PASS 895 + 896 + **Step 5: Commit** 897 + 898 + ```bash 899 + cd /Users/chadmiller/code/swell 900 + git add src/swell/security.gleam test/security_test.gleam 901 + git commit -m "feat(security): add unified validate_all function" 902 + ``` 903 + 904 + --- 905 + 906 + ### Task 7: Integrate Security into Executor 907 + 908 + **Files:** 909 + - Modify: `/Users/chadmiller/code/swell/src/swell/executor.gleam` 910 + - Test: `/Users/chadmiller/code/swell/test/executor_security_test.gleam` 911 + 912 + **Step 1: Write the failing test** 913 + 914 + ```gleam 915 + // test/executor_security_test.gleam 916 + import gleeunit/should 917 + import gleam/option 918 + import swell/executor 919 + import swell/schema 920 + import swell/security 921 + 922 + pub fn execute_with_security_rejects_deep_query_test() { 923 + // Build a simple schema 924 + let user_type = schema.object_type("User", [ 925 + schema.field("name", schema.string(), fn(_) { Ok(swell/value.String("test")) }), 926 + ]) 927 + let query_type = schema.object_type("Query", [ 928 + schema.field("user", user_type, fn(_) { Ok(swell/value.Object([#("name", swell/value.String("test"))])) }), 929 + ]) 930 + let test_schema = schema.schema(query_type) 931 + 932 + // Deep query that exceeds limit 933 + let query = "{ user { user { user { user { user { name } } } } } }" 934 + let ctx = schema.context(option.None) 935 + let config = security.SecurityConfig(..security.default_config(), max_depth: 3) 936 + 937 + case executor.execute_with_security(query, test_schema, ctx, config) { 938 + Error(msg) -> { 939 + msg |> should.equal("Query depth 5 exceeds maximum 3") 940 + } 941 + Ok(_) -> should.fail() 942 + } 943 + } 944 + ``` 945 + 946 + **Step 2: Run test to verify it fails** 947 + 948 + Run: `cd /Users/chadmiller/code/swell && gleam test` 949 + Expected: FAIL with "execute_with_security not found" 950 + 951 + **Step 3: Write minimal implementation** 952 + 953 + ```gleam 954 + // Add to src/swell/executor.gleam after imports 955 + import swell/security 956 + 957 + /// Execute a GraphQL query with security validation 958 + pub fn execute_with_security( 959 + query: String, 960 + graphql_schema: schema.Schema, 961 + ctx: schema.Context, 962 + security_config: security.SecurityConfig, 963 + ) -> Result(Response, String) { 964 + // Parse the query 965 + case parser.parse(query) { 966 + Error(parse_error) -> 967 + Error("Parse error: " <> format_parse_error(parse_error)) 968 + Ok(document) -> { 969 + // Run security validations 970 + case security.validate_all(document, ctx, security_config) { 971 + Error(security_error) -> 972 + Error(security.error_message(security_error)) 973 + Ok(_) -> { 974 + // Build canonical type registry for union resolution 975 + let type_registry = build_type_registry(graphql_schema) 976 + 977 + // Execute the document 978 + case execute_document(document, graphql_schema, ctx, type_registry) { 979 + Ok(#(data, errors)) -> Ok(Response(data, errors)) 980 + Error(err) -> Error(err) 981 + } 982 + } 983 + } 984 + } 985 + } 986 + } 987 + ``` 988 + 989 + **Step 4: Run test to verify it passes** 990 + 991 + Run: `cd /Users/chadmiller/code/swell && gleam test` 992 + Expected: PASS 993 + 994 + **Step 5: Commit** 995 + 996 + ```bash 997 + cd /Users/chadmiller/code/swell 998 + git add src/swell/executor.gleam test/executor_security_test.gleam 999 + git commit -m "feat(executor): integrate security validation into execution" 1000 + ``` 1001 + 1002 + --- 1003 + 1004 + ## Part 2: Lexicon GraphQL Custom Scalars 1005 + 1006 + ### Task 8: Create AtUri Scalar 1007 + 1008 + **Files:** 1009 + - Create: `/Users/chadmiller/code/quickslice/lexicon_graphql/src/lexicon_graphql/scalar/at_uri.gleam` 1010 + - Test: `/Users/chadmiller/code/quickslice/lexicon_graphql/test/scalar/at_uri_test.gleam` 1011 + 1012 + **Step 1: Write the failing test** 1013 + 1014 + ```gleam 1015 + // test/scalar/at_uri_test.gleam 1016 + import gleeunit/should 1017 + import lexicon_graphql/scalar/at_uri 1018 + import swell/value 1019 + 1020 + pub fn parse_valid_at_uri_test() { 1021 + let input = value.String("at://did:plc:abc123/app.bsky.feed.post/xyz") 1022 + 1023 + case at_uri.parse(input) { 1024 + Ok(value.String(s)) -> s |> should.equal("at://did:plc:abc123/app.bsky.feed.post/xyz") 1025 + _ -> should.fail() 1026 + } 1027 + } 1028 + 1029 + pub fn parse_invalid_at_uri_test() { 1030 + let input = value.String("https://example.com") 1031 + 1032 + case at_uri.parse(input) { 1033 + Error(msg) -> msg |> should.equal("AtUri must start with 'at://'") 1034 + _ -> should.fail() 1035 + } 1036 + } 1037 + 1038 + pub fn parse_malformed_at_uri_test() { 1039 + let input = value.String("at://notadid/collection") 1040 + 1041 + case at_uri.parse(input) { 1042 + Error(msg) -> msg |> should.equal("AtUri has invalid DID format") 1043 + _ -> should.fail() 1044 + } 1045 + } 1046 + 1047 + pub fn scalar_definition_test() { 1048 + let scalar = at_uri.scalar() 1049 + 1050 + scalar.name |> should.equal("AtUri") 1051 + } 1052 + ``` 1053 + 1054 + **Step 2: Run test to verify it fails** 1055 + 1056 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1057 + Expected: FAIL with "module lexicon_graphql/scalar/at_uri not found" 1058 + 1059 + **Step 3: Write minimal implementation** 1060 + 1061 + ```gleam 1062 + // src/lexicon_graphql/scalar/at_uri.gleam 1063 + import gleam/string 1064 + import swell/schema 1065 + import swell/value 1066 + 1067 + /// AT Protocol URI scalar type 1068 + /// Format: at://did/collection/rkey 1069 + pub fn scalar() -> schema.Scalar { 1070 + schema.Scalar( 1071 + name: "AtUri", 1072 + description: "AT Protocol URI (at://did/collection/rkey)", 1073 + parse: parse, 1074 + serialize: serialize, 1075 + ) 1076 + } 1077 + 1078 + pub fn parse(input: value.Value) -> Result(value.Value, String) { 1079 + case input { 1080 + value.String(s) -> validate_at_uri(s) 1081 + value.Null -> Ok(value.Null) 1082 + _ -> Error("AtUri must be a string") 1083 + } 1084 + } 1085 + 1086 + pub fn serialize(val: value.Value) -> value.Value { 1087 + val 1088 + } 1089 + 1090 + fn validate_at_uri(s: String) -> Result(value.Value, String) { 1091 + case string.starts_with(s, "at://") { 1092 + False -> Error("AtUri must start with 'at://'") 1093 + True -> { 1094 + // Extract the part after "at://" 1095 + let rest = string.drop_start(s, 5) 1096 + 1097 + // Check for valid DID (must start with "did:") 1098 + case string.starts_with(rest, "did:") { 1099 + False -> Error("AtUri has invalid DID format") 1100 + True -> { 1101 + // Check for at least one path segment after DID 1102 + case string.contains(rest, "/") { 1103 + False -> Error("AtUri missing collection path") 1104 + True -> Ok(value.String(s)) 1105 + } 1106 + } 1107 + } 1108 + } 1109 + } 1110 + } 1111 + ``` 1112 + 1113 + **Step 4: Run test to verify it passes** 1114 + 1115 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1116 + Expected: PASS 1117 + 1118 + **Step 5: Commit** 1119 + 1120 + ```bash 1121 + cd /Users/chadmiller/code/quickslice/lexicon_graphql 1122 + git add src/lexicon_graphql/scalar/at_uri.gleam test/scalar/at_uri_test.gleam 1123 + git commit -m "feat(scalar): add AtUri custom scalar with validation" 1124 + ``` 1125 + 1126 + --- 1127 + 1128 + ### Task 9: Create Did Scalar 1129 + 1130 + **Files:** 1131 + - Create: `/Users/chadmiller/code/quickslice/lexicon_graphql/src/lexicon_graphql/scalar/did.gleam` 1132 + - Test: `/Users/chadmiller/code/quickslice/lexicon_graphql/test/scalar/did_test.gleam` 1133 + 1134 + **Step 1: Write the failing test** 1135 + 1136 + ```gleam 1137 + // test/scalar/did_test.gleam 1138 + import gleeunit/should 1139 + import lexicon_graphql/scalar/did 1140 + import swell/value 1141 + 1142 + pub fn parse_valid_did_plc_test() { 1143 + let input = value.String("did:plc:abc123xyz") 1144 + 1145 + case did.parse(input) { 1146 + Ok(value.String(s)) -> s |> should.equal("did:plc:abc123xyz") 1147 + _ -> should.fail() 1148 + } 1149 + } 1150 + 1151 + pub fn parse_valid_did_web_test() { 1152 + let input = value.String("did:web:example.com") 1153 + 1154 + case did.parse(input) { 1155 + Ok(value.String(s)) -> s |> should.equal("did:web:example.com") 1156 + _ -> should.fail() 1157 + } 1158 + } 1159 + 1160 + pub fn parse_invalid_did_test() { 1161 + let input = value.String("not-a-did") 1162 + 1163 + case did.parse(input) { 1164 + Error(msg) -> msg |> should.equal("Did must start with 'did:'") 1165 + _ -> should.fail() 1166 + } 1167 + } 1168 + 1169 + pub fn parse_did_missing_method_test() { 1170 + let input = value.String("did:") 1171 + 1172 + case did.parse(input) { 1173 + Error(msg) -> msg |> should.equal("Did missing method") 1174 + _ -> should.fail() 1175 + } 1176 + } 1177 + ``` 1178 + 1179 + **Step 2: Run test to verify it fails** 1180 + 1181 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1182 + Expected: FAIL with "module lexicon_graphql/scalar/did not found" 1183 + 1184 + **Step 3: Write minimal implementation** 1185 + 1186 + ```gleam 1187 + // src/lexicon_graphql/scalar/did.gleam 1188 + import gleam/string 1189 + import swell/schema 1190 + import swell/value 1191 + 1192 + /// Decentralized Identifier scalar type 1193 + /// Format: did:method:identifier 1194 + pub fn scalar() -> schema.Scalar { 1195 + schema.Scalar( 1196 + name: "Did", 1197 + description: "Decentralized Identifier (did:method:identifier)", 1198 + parse: parse, 1199 + serialize: serialize, 1200 + ) 1201 + } 1202 + 1203 + pub fn parse(input: value.Value) -> Result(value.Value, String) { 1204 + case input { 1205 + value.String(s) -> validate_did(s) 1206 + value.Null -> Ok(value.Null) 1207 + _ -> Error("Did must be a string") 1208 + } 1209 + } 1210 + 1211 + pub fn serialize(val: value.Value) -> value.Value { 1212 + val 1213 + } 1214 + 1215 + fn validate_did(s: String) -> Result(value.Value, String) { 1216 + case string.starts_with(s, "did:") { 1217 + False -> Error("Did must start with 'did:'") 1218 + True -> { 1219 + let rest = string.drop_start(s, 4) 1220 + case string.length(rest) > 0 { 1221 + False -> Error("Did missing method") 1222 + True -> { 1223 + // Check for method:identifier pattern 1224 + case string.contains(rest, ":") { 1225 + False -> Error("Did missing identifier") 1226 + True -> Ok(value.String(s)) 1227 + } 1228 + } 1229 + } 1230 + } 1231 + } 1232 + } 1233 + ``` 1234 + 1235 + **Step 4: Run test to verify it passes** 1236 + 1237 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1238 + Expected: PASS 1239 + 1240 + **Step 5: Commit** 1241 + 1242 + ```bash 1243 + cd /Users/chadmiller/code/quickslice/lexicon_graphql 1244 + git add src/lexicon_graphql/scalar/did.gleam test/scalar/did_test.gleam 1245 + git commit -m "feat(scalar): add Did custom scalar with validation" 1246 + ``` 1247 + 1248 + --- 1249 + 1250 + ### Task 10: Create Handle Scalar 1251 + 1252 + **Files:** 1253 + - Create: `/Users/chadmiller/code/quickslice/lexicon_graphql/src/lexicon_graphql/scalar/handle.gleam` 1254 + - Test: `/Users/chadmiller/code/quickslice/lexicon_graphql/test/scalar/handle_test.gleam` 1255 + 1256 + **Step 1: Write the failing test** 1257 + 1258 + ```gleam 1259 + // test/scalar/handle_test.gleam 1260 + import gleeunit/should 1261 + import lexicon_graphql/scalar/handle 1262 + import swell/value 1263 + 1264 + pub fn parse_valid_handle_test() { 1265 + let input = value.String("alice.bsky.social") 1266 + 1267 + case handle.parse(input) { 1268 + Ok(value.String(s)) -> s |> should.equal("alice.bsky.social") 1269 + _ -> should.fail() 1270 + } 1271 + } 1272 + 1273 + pub fn parse_valid_custom_domain_test() { 1274 + let input = value.String("alice.example.com") 1275 + 1276 + case handle.parse(input) { 1277 + Ok(value.String(s)) -> s |> should.equal("alice.example.com") 1278 + _ -> should.fail() 1279 + } 1280 + } 1281 + 1282 + pub fn parse_invalid_no_dot_test() { 1283 + let input = value.String("alice") 1284 + 1285 + case handle.parse(input) { 1286 + Error(msg) -> msg |> should.equal("Handle must contain at least one dot") 1287 + _ -> should.fail() 1288 + } 1289 + } 1290 + 1291 + pub fn parse_invalid_starts_with_dot_test() { 1292 + let input = value.String(".alice.bsky.social") 1293 + 1294 + case handle.parse(input) { 1295 + Error(msg) -> msg |> should.equal("Handle cannot start with a dot") 1296 + _ -> should.fail() 1297 + } 1298 + } 1299 + ``` 1300 + 1301 + **Step 2: Run test to verify it fails** 1302 + 1303 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1304 + Expected: FAIL with "module lexicon_graphql/scalar/handle not found" 1305 + 1306 + **Step 3: Write minimal implementation** 1307 + 1308 + ```gleam 1309 + // src/lexicon_graphql/scalar/handle.gleam 1310 + import gleam/string 1311 + import swell/schema 1312 + import swell/value 1313 + 1314 + /// AT Protocol Handle scalar type 1315 + /// Format: username.domain.tld 1316 + pub fn scalar() -> schema.Scalar { 1317 + schema.Scalar( 1318 + name: "Handle", 1319 + description: "AT Protocol handle (username.domain.tld)", 1320 + parse: parse, 1321 + serialize: serialize, 1322 + ) 1323 + } 1324 + 1325 + pub fn parse(input: value.Value) -> Result(value.Value, String) { 1326 + case input { 1327 + value.String(s) -> validate_handle(s) 1328 + value.Null -> Ok(value.Null) 1329 + _ -> Error("Handle must be a string") 1330 + } 1331 + } 1332 + 1333 + pub fn serialize(val: value.Value) -> value.Value { 1334 + val 1335 + } 1336 + 1337 + fn validate_handle(s: String) -> Result(value.Value, String) { 1338 + case string.starts_with(s, ".") { 1339 + True -> Error("Handle cannot start with a dot") 1340 + False -> { 1341 + case string.ends_with(s, ".") { 1342 + True -> Error("Handle cannot end with a dot") 1343 + False -> { 1344 + case string.contains(s, ".") { 1345 + False -> Error("Handle must contain at least one dot") 1346 + True -> { 1347 + // Basic length check (handles are max 253 chars per DNS) 1348 + case string.length(s) > 253 { 1349 + True -> Error("Handle exceeds maximum length") 1350 + False -> Ok(value.String(s)) 1351 + } 1352 + } 1353 + } 1354 + } 1355 + } 1356 + } 1357 + } 1358 + } 1359 + ``` 1360 + 1361 + **Step 4: Run test to verify it passes** 1362 + 1363 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1364 + Expected: PASS 1365 + 1366 + **Step 5: Commit** 1367 + 1368 + ```bash 1369 + cd /Users/chadmiller/code/quickslice/lexicon_graphql 1370 + git add src/lexicon_graphql/scalar/handle.gleam test/scalar/handle_test.gleam 1371 + git commit -m "feat(scalar): add Handle custom scalar with validation" 1372 + ``` 1373 + 1374 + --- 1375 + 1376 + ### Task 11: Create DateTime Scalar 1377 + 1378 + **Files:** 1379 + - Create: `/Users/chadmiller/code/quickslice/lexicon_graphql/src/lexicon_graphql/scalar/datetime.gleam` 1380 + - Test: `/Users/chadmiller/code/quickslice/lexicon_graphql/test/scalar/datetime_test.gleam` 1381 + 1382 + **Step 1: Write the failing test** 1383 + 1384 + ```gleam 1385 + // test/scalar/datetime_test.gleam 1386 + import gleeunit/should 1387 + import lexicon_graphql/scalar/datetime 1388 + import swell/value 1389 + 1390 + pub fn parse_valid_datetime_z_test() { 1391 + let input = value.String("2024-01-15T10:30:00Z") 1392 + 1393 + case datetime.parse(input) { 1394 + Ok(value.String(s)) -> s |> should.equal("2024-01-15T10:30:00Z") 1395 + _ -> should.fail() 1396 + } 1397 + } 1398 + 1399 + pub fn parse_valid_datetime_offset_test() { 1400 + let input = value.String("2024-01-15T10:30:00+05:00") 1401 + 1402 + case datetime.parse(input) { 1403 + Ok(value.String(s)) -> s |> should.equal("2024-01-15T10:30:00+05:00") 1404 + _ -> should.fail() 1405 + } 1406 + } 1407 + 1408 + pub fn parse_valid_datetime_millis_test() { 1409 + let input = value.String("2024-01-15T10:30:00.123Z") 1410 + 1411 + case datetime.parse(input) { 1412 + Ok(value.String(s)) -> s |> should.equal("2024-01-15T10:30:00.123Z") 1413 + _ -> should.fail() 1414 + } 1415 + } 1416 + 1417 + pub fn parse_invalid_datetime_test() { 1418 + let input = value.String("not-a-date") 1419 + 1420 + case datetime.parse(input) { 1421 + Error(msg) -> msg |> should.equal("DateTime must be in ISO 8601 format") 1422 + _ -> should.fail() 1423 + } 1424 + } 1425 + ``` 1426 + 1427 + **Step 2: Run test to verify it fails** 1428 + 1429 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1430 + Expected: FAIL with "module lexicon_graphql/scalar/datetime not found" 1431 + 1432 + **Step 3: Write minimal implementation** 1433 + 1434 + ```gleam 1435 + // src/lexicon_graphql/scalar/datetime.gleam 1436 + import gleam/regex 1437 + import gleam/string 1438 + import swell/schema 1439 + import swell/value 1440 + 1441 + /// ISO 8601 DateTime scalar type 1442 + pub fn scalar() -> schema.Scalar { 1443 + schema.Scalar( 1444 + name: "DateTime", 1445 + description: "ISO 8601 datetime string", 1446 + parse: parse, 1447 + serialize: serialize, 1448 + ) 1449 + } 1450 + 1451 + pub fn parse(input: value.Value) -> Result(value.Value, String) { 1452 + case input { 1453 + value.String(s) -> validate_datetime(s) 1454 + value.Null -> Ok(value.Null) 1455 + _ -> Error("DateTime must be a string") 1456 + } 1457 + } 1458 + 1459 + pub fn serialize(val: value.Value) -> value.Value { 1460 + val 1461 + } 1462 + 1463 + fn validate_datetime(s: String) -> Result(value.Value, String) { 1464 + // ISO 8601 pattern: YYYY-MM-DDTHH:MM:SS[.sss](Z|+HH:MM|-HH:MM) 1465 + let assert Ok(pattern) = regex.from_string( 1466 + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$" 1467 + ) 1468 + 1469 + case regex.check(pattern, s) { 1470 + True -> Ok(value.String(s)) 1471 + False -> Error("DateTime must be in ISO 8601 format") 1472 + } 1473 + } 1474 + ``` 1475 + 1476 + **Step 4: Run test to verify it passes** 1477 + 1478 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1479 + Expected: PASS 1480 + 1481 + **Step 5: Commit** 1482 + 1483 + ```bash 1484 + cd /Users/chadmiller/code/quickslice/lexicon_graphql 1485 + git add src/lexicon_graphql/scalar/datetime.gleam test/scalar/datetime_test.gleam 1486 + git commit -m "feat(scalar): add DateTime custom scalar with ISO 8601 validation" 1487 + ``` 1488 + 1489 + --- 1490 + 1491 + ### Task 12: Integrate Scalars into Type Mapper 1492 + 1493 + **Files:** 1494 + - Modify: `/Users/chadmiller/code/quickslice/lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam` 1495 + - Test: `/Users/chadmiller/code/quickslice/lexicon_graphql/test/type_mapper_scalar_test.gleam` 1496 + 1497 + **Step 1: Write the failing test** 1498 + 1499 + ```gleam 1500 + // test/type_mapper_scalar_test.gleam 1501 + import gleeunit/should 1502 + import lexicon_graphql/types 1503 + import lexicon_graphql/internal/graphql/type_mapper 1504 + import gleam/option.{None, Some} 1505 + 1506 + pub fn map_at_uri_format_test() { 1507 + let property = types.Property( 1508 + type_: "string", 1509 + required: True, 1510 + format: Some("at-uri"), 1511 + ref: None, 1512 + refs: None, 1513 + items: None, 1514 + ) 1515 + 1516 + let result = type_mapper.property_to_graphql_type(property) 1517 + // Should return AtUri scalar type 1518 + result.name |> should.equal("AtUri") 1519 + } 1520 + 1521 + pub fn map_did_format_test() { 1522 + let property = types.Property( 1523 + type_: "string", 1524 + required: True, 1525 + format: Some("did"), 1526 + ref: None, 1527 + refs: None, 1528 + items: None, 1529 + ) 1530 + 1531 + let result = type_mapper.property_to_graphql_type(property) 1532 + result.name |> should.equal("Did") 1533 + } 1534 + 1535 + pub fn map_handle_format_test() { 1536 + let property = types.Property( 1537 + type_: "string", 1538 + required: True, 1539 + format: Some("handle"), 1540 + ref: None, 1541 + refs: None, 1542 + items: None, 1543 + ) 1544 + 1545 + let result = type_mapper.property_to_graphql_type(property) 1546 + result.name |> should.equal("Handle") 1547 + } 1548 + 1549 + pub fn map_datetime_format_test() { 1550 + let property = types.Property( 1551 + type_: "string", 1552 + required: True, 1553 + format: Some("datetime"), 1554 + ref: None, 1555 + refs: None, 1556 + items: None, 1557 + ) 1558 + 1559 + let result = type_mapper.property_to_graphql_type(property) 1560 + result.name |> should.equal("DateTime") 1561 + } 1562 + ``` 1563 + 1564 + **Step 2: Run test to verify it fails** 1565 + 1566 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1567 + Expected: FAIL (test may pass if mapping exists, or fail with wrong type name) 1568 + 1569 + **Step 3: Write minimal implementation** 1570 + 1571 + Add to type_mapper.gleam's string type mapping section: 1572 + 1573 + ```gleam 1574 + // In type_mapper.gleam, update the string type mapping function 1575 + import lexicon_graphql/scalar/at_uri 1576 + import lexicon_graphql/scalar/did 1577 + import lexicon_graphql/scalar/handle 1578 + import lexicon_graphql/scalar/datetime 1579 + 1580 + fn map_string_type(property: types.Property) -> schema.Type { 1581 + case property.format { 1582 + option.Some("at-uri") -> schema.scalar_type(at_uri.scalar()) 1583 + option.Some("did") -> schema.scalar_type(did.scalar()) 1584 + option.Some("handle") -> schema.scalar_type(handle.scalar()) 1585 + option.Some("datetime") -> schema.scalar_type(datetime.scalar()) 1586 + option.Some("uri") -> schema.string() // Regular URI, no validation 1587 + option.Some("cid-link") -> schema.string() // CID, no validation 1588 + _ -> schema.string() 1589 + } 1590 + } 1591 + ``` 1592 + 1593 + **Step 4: Run test to verify it passes** 1594 + 1595 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1596 + Expected: PASS 1597 + 1598 + **Step 5: Commit** 1599 + 1600 + ```bash 1601 + cd /Users/chadmiller/code/quickslice/lexicon_graphql 1602 + git add src/lexicon_graphql/internal/graphql/type_mapper.gleam test/type_mapper_scalar_test.gleam 1603 + git commit -m "feat(type_mapper): integrate custom scalars for AT Protocol formats" 1604 + ``` 1605 + 1606 + --- 1607 + 1608 + ## Part 3: Constraint Validation in lexicon_graphql 1609 + 1610 + ### Task 13: Extend Property Type with Constraints 1611 + 1612 + **Files:** 1613 + - Modify: `/Users/chadmiller/code/quickslice/lexicon_graphql/src/lexicon_graphql/types.gleam` 1614 + - Test: `/Users/chadmiller/code/quickslice/lexicon_graphql/test/types_test.gleam` 1615 + 1616 + **Step 1: Write the failing test** 1617 + 1618 + ```gleam 1619 + // test/types_test.gleam 1620 + import gleeunit/should 1621 + import lexicon_graphql/types 1622 + import gleam/option.{None, Some} 1623 + 1624 + pub fn property_with_max_length_test() { 1625 + let prop = types.Property( 1626 + type_: "string", 1627 + required: True, 1628 + format: None, 1629 + ref: None, 1630 + refs: None, 1631 + items: None, 1632 + max_length: Some(128), 1633 + min_length: None, 1634 + max_graphemes: None, 1635 + min_graphemes: None, 1636 + pattern: None, 1637 + ) 1638 + 1639 + prop.max_length |> should.equal(Some(128)) 1640 + } 1641 + 1642 + pub fn property_with_pattern_test() { 1643 + let prop = types.Property( 1644 + type_: "string", 1645 + required: True, 1646 + format: None, 1647 + ref: None, 1648 + refs: None, 1649 + items: None, 1650 + max_length: None, 1651 + min_length: None, 1652 + max_graphemes: None, 1653 + min_graphemes: None, 1654 + pattern: Some("^[a-z]+$"), 1655 + ) 1656 + 1657 + prop.pattern |> should.equal(Some("^[a-z]+$")) 1658 + } 1659 + ``` 1660 + 1661 + **Step 2: Run test to verify it fails** 1662 + 1663 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1664 + Expected: FAIL with "Property does not have field max_length" 1665 + 1666 + **Step 3: Write minimal implementation** 1667 + 1668 + ```gleam 1669 + // Update src/lexicon_graphql/types.gleam 1670 + import gleam/option.{type Option} 1671 + 1672 + /// Property definition with constraint fields 1673 + pub type Property { 1674 + Property( 1675 + type_: String, 1676 + required: Bool, 1677 + format: Option(String), 1678 + ref: Option(String), 1679 + refs: Option(List(String)), 1680 + items: Option(ArrayItems), 1681 + // Constraint fields 1682 + max_length: Option(Int), 1683 + min_length: Option(Int), 1684 + max_graphemes: Option(Int), 1685 + min_graphemes: Option(Int), 1686 + pattern: Option(String), 1687 + ) 1688 + } 1689 + 1690 + /// Create a Property with default constraint values (all None) 1691 + pub fn property( 1692 + type_: String, 1693 + required: Bool, 1694 + format: Option(String), 1695 + ref: Option(String), 1696 + refs: Option(List(String)), 1697 + items: Option(ArrayItems), 1698 + ) -> Property { 1699 + Property( 1700 + type_: type_, 1701 + required: required, 1702 + format: format, 1703 + ref: ref, 1704 + refs: refs, 1705 + items: items, 1706 + max_length: option.None, 1707 + min_length: option.None, 1708 + max_graphemes: option.None, 1709 + min_graphemes: option.None, 1710 + pattern: option.None, 1711 + ) 1712 + } 1713 + ``` 1714 + 1715 + **Step 4: Run test to verify it passes** 1716 + 1717 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1718 + Expected: PASS (after fixing all Property constructors in codebase) 1719 + 1720 + **Step 5: Commit** 1721 + 1722 + ```bash 1723 + cd /Users/chadmiller/code/quickslice/lexicon_graphql 1724 + git add src/lexicon_graphql/types.gleam test/types_test.gleam 1725 + git commit -m "feat(types): add constraint fields to Property type" 1726 + ``` 1727 + 1728 + --- 1729 + 1730 + ### Task 14: Update Parser to Extract Constraints 1731 + 1732 + **Files:** 1733 + - Modify: `/Users/chadmiller/code/quickslice/lexicon_graphql/src/lexicon_graphql/internal/lexicon/parser.gleam` 1734 + - Test: `/Users/chadmiller/code/quickslice/lexicon_graphql/test/parser_constraints_test.gleam` 1735 + 1736 + **Step 1: Write the failing test** 1737 + 1738 + ```gleam 1739 + // test/parser_constraints_test.gleam 1740 + import gleeunit/should 1741 + import lexicon_graphql/internal/lexicon/parser 1742 + import gleam/option.{Some} 1743 + 1744 + pub fn parse_property_with_max_length_test() { 1745 + let json = "{\"type\": \"string\", \"maxLength\": 128}" 1746 + 1747 + case parser.parse_property_json(json) { 1748 + Ok(prop) -> prop.max_length |> should.equal(Some(128)) 1749 + Error(_) -> should.fail() 1750 + } 1751 + } 1752 + 1753 + pub fn parse_property_with_pattern_test() { 1754 + let json = "{\"type\": \"string\", \"pattern\": \"^[a-z]+$\"}" 1755 + 1756 + case parser.parse_property_json(json) { 1757 + Ok(prop) -> prop.pattern |> should.equal(Some("^[a-z]+$")) 1758 + Error(_) -> should.fail() 1759 + } 1760 + } 1761 + 1762 + pub fn parse_property_with_multiple_constraints_test() { 1763 + let json = "{\"type\": \"string\", \"minLength\": 1, \"maxLength\": 100, \"maxGraphemes\": 50}" 1764 + 1765 + case parser.parse_property_json(json) { 1766 + Ok(prop) -> { 1767 + prop.min_length |> should.equal(Some(1)) 1768 + prop.max_length |> should.equal(Some(100)) 1769 + prop.max_graphemes |> should.equal(Some(50)) 1770 + } 1771 + Error(_) -> should.fail() 1772 + } 1773 + } 1774 + ``` 1775 + 1776 + **Step 2: Run test to verify it fails** 1777 + 1778 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1779 + Expected: FAIL (constraints not being parsed) 1780 + 1781 + **Step 3: Write minimal implementation** 1782 + 1783 + Update parser.gleam to extract constraint fields: 1784 + 1785 + ```gleam 1786 + // In parser.gleam, update parse_property function 1787 + fn parse_property(json: Dynamic) -> Result(types.Property, List(dynamic.DecodeError)) { 1788 + use type_ <- result.try(dynamic.field("type", dynamic.string)(json)) 1789 + use format <- result.try(get_optional_string(json, "format")) 1790 + use ref <- result.try(get_optional_string(json, "ref")) 1791 + use refs <- result.try(get_optional_string_list(json, "refs")) 1792 + use items <- result.try(get_optional_items(json)) 1793 + 1794 + // Parse constraint fields 1795 + use max_length <- result.try(get_optional_int(json, "maxLength")) 1796 + use min_length <- result.try(get_optional_int(json, "minLength")) 1797 + use max_graphemes <- result.try(get_optional_int(json, "maxGraphemes")) 1798 + use min_graphemes <- result.try(get_optional_int(json, "minGraphemes")) 1799 + use pattern <- result.try(get_optional_string(json, "pattern")) 1800 + 1801 + Ok(types.Property( 1802 + type_: type_, 1803 + required: False, // Set based on required array later 1804 + format: format, 1805 + ref: ref, 1806 + refs: refs, 1807 + items: items, 1808 + max_length: max_length, 1809 + min_length: min_length, 1810 + max_graphemes: max_graphemes, 1811 + min_graphemes: min_graphemes, 1812 + pattern: pattern, 1813 + )) 1814 + } 1815 + 1816 + fn get_optional_int( 1817 + json: Dynamic, 1818 + field_name: String, 1819 + ) -> Result(Option(Int), List(dynamic.DecodeError)) { 1820 + case dynamic.field(field_name, dynamic.int)(json) { 1821 + Ok(n) -> Ok(option.Some(n)) 1822 + Error(_) -> Ok(option.None) 1823 + } 1824 + } 1825 + ``` 1826 + 1827 + **Step 4: Run test to verify it passes** 1828 + 1829 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1830 + Expected: PASS 1831 + 1832 + **Step 5: Commit** 1833 + 1834 + ```bash 1835 + cd /Users/chadmiller/code/quickslice/lexicon_graphql 1836 + git add src/lexicon_graphql/internal/lexicon/parser.gleam test/parser_constraints_test.gleam 1837 + git commit -m "feat(parser): extract constraint fields from lexicon JSON" 1838 + ``` 1839 + 1840 + --- 1841 + 1842 + ### Task 15: Create Input Validator Module 1843 + 1844 + **Files:** 1845 + - Create: `/Users/chadmiller/code/quickslice/lexicon_graphql/src/lexicon_graphql/validation/input.gleam` 1846 + - Test: `/Users/chadmiller/code/quickslice/lexicon_graphql/test/validation/input_test.gleam` 1847 + 1848 + **Step 1: Write the failing test** 1849 + 1850 + ```gleam 1851 + // test/validation/input_test.gleam 1852 + import gleeunit/should 1853 + import lexicon_graphql/validation/input 1854 + import lexicon_graphql/types 1855 + import swell/value 1856 + import gleam/option.{None, Some} 1857 + 1858 + pub fn validate_max_length_passes_test() { 1859 + let prop = types.Property( 1860 + type_: "string", 1861 + required: True, 1862 + format: None, 1863 + ref: None, 1864 + refs: None, 1865 + items: None, 1866 + max_length: Some(10), 1867 + min_length: None, 1868 + max_graphemes: None, 1869 + min_graphemes: None, 1870 + pattern: None, 1871 + ) 1872 + 1873 + input.validate(value.String("hello"), prop, "name") 1874 + |> should.be_ok() 1875 + } 1876 + 1877 + pub fn validate_max_length_fails_test() { 1878 + let prop = types.Property( 1879 + type_: "string", 1880 + required: True, 1881 + format: None, 1882 + ref: None, 1883 + refs: None, 1884 + items: None, 1885 + max_length: Some(5), 1886 + min_length: None, 1887 + max_graphemes: None, 1888 + min_graphemes: None, 1889 + pattern: None, 1890 + ) 1891 + 1892 + case input.validate(value.String("hello world"), prop, "name") { 1893 + Error(msg) -> msg |> should.equal("name exceeds maximum length of 5") 1894 + Ok(_) -> should.fail() 1895 + } 1896 + } 1897 + 1898 + pub fn validate_pattern_passes_test() { 1899 + let prop = types.Property( 1900 + type_: "string", 1901 + required: True, 1902 + format: None, 1903 + ref: None, 1904 + refs: None, 1905 + items: None, 1906 + max_length: None, 1907 + min_length: None, 1908 + max_graphemes: None, 1909 + min_graphemes: None, 1910 + pattern: Some("^[a-z]+$"), 1911 + ) 1912 + 1913 + input.validate(value.String("hello"), prop, "name") 1914 + |> should.be_ok() 1915 + } 1916 + 1917 + pub fn validate_pattern_fails_test() { 1918 + let prop = types.Property( 1919 + type_: "string", 1920 + required: True, 1921 + format: None, 1922 + ref: None, 1923 + refs: None, 1924 + items: None, 1925 + max_length: None, 1926 + min_length: None, 1927 + max_graphemes: None, 1928 + min_graphemes: None, 1929 + pattern: Some("^[a-z]+$"), 1930 + ) 1931 + 1932 + case input.validate(value.String("Hello123"), prop, "name") { 1933 + Error(msg) -> msg |> should.equal("name does not match required pattern") 1934 + Ok(_) -> should.fail() 1935 + } 1936 + } 1937 + ``` 1938 + 1939 + **Step 2: Run test to verify it fails** 1940 + 1941 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 1942 + Expected: FAIL with "module lexicon_graphql/validation/input not found" 1943 + 1944 + **Step 3: Write minimal implementation** 1945 + 1946 + ```gleam 1947 + // src/lexicon_graphql/validation/input.gleam 1948 + import gleam/int 1949 + import gleam/option.{type Option, None, Some} 1950 + import gleam/regex 1951 + import gleam/result 1952 + import gleam/string 1953 + import lexicon_graphql/types 1954 + import swell/value 1955 + 1956 + /// Validate an input value against property constraints 1957 + pub fn validate( 1958 + input: value.Value, 1959 + property: types.Property, 1960 + field_name: String, 1961 + ) -> Result(value.Value, String) { 1962 + case input { 1963 + value.String(s) -> validate_string(s, property, field_name) 1964 + value.Null -> Ok(value.Null) 1965 + _ -> Ok(input) // Non-string types pass through 1966 + } 1967 + } 1968 + 1969 + fn validate_string( 1970 + s: String, 1971 + property: types.Property, 1972 + field_name: String, 1973 + ) -> Result(value.Value, String) { 1974 + // Check max_length 1975 + use _ <- result.try(validate_max_length(s, property.max_length, field_name)) 1976 + 1977 + // Check min_length 1978 + use _ <- result.try(validate_min_length(s, property.min_length, field_name)) 1979 + 1980 + // Check pattern 1981 + use _ <- result.try(validate_pattern(s, property.pattern, field_name)) 1982 + 1983 + Ok(value.String(s)) 1984 + } 1985 + 1986 + fn validate_max_length( 1987 + s: String, 1988 + max: Option(Int), 1989 + field_name: String, 1990 + ) -> Result(Nil, String) { 1991 + case max { 1992 + None -> Ok(Nil) 1993 + Some(max_len) -> { 1994 + case string.length(s) > max_len { 1995 + True -> Error(field_name <> " exceeds maximum length of " <> int.to_string(max_len)) 1996 + False -> Ok(Nil) 1997 + } 1998 + } 1999 + } 2000 + } 2001 + 2002 + fn validate_min_length( 2003 + s: String, 2004 + min: Option(Int), 2005 + field_name: String, 2006 + ) -> Result(Nil, String) { 2007 + case min { 2008 + None -> Ok(Nil) 2009 + Some(min_len) -> { 2010 + case string.length(s) < min_len { 2011 + True -> Error(field_name <> " must be at least " <> int.to_string(min_len) <> " characters") 2012 + False -> Ok(Nil) 2013 + } 2014 + } 2015 + } 2016 + } 2017 + 2018 + fn validate_pattern( 2019 + s: String, 2020 + pattern: Option(String), 2021 + field_name: String, 2022 + ) -> Result(Nil, String) { 2023 + case pattern { 2024 + None -> Ok(Nil) 2025 + Some(pattern_str) -> { 2026 + case regex.from_string(pattern_str) { 2027 + Error(_) -> Ok(Nil) // Invalid pattern, skip validation 2028 + Ok(re) -> { 2029 + case regex.check(re, s) { 2030 + True -> Ok(Nil) 2031 + False -> Error(field_name <> " does not match required pattern") 2032 + } 2033 + } 2034 + } 2035 + } 2036 + } 2037 + } 2038 + ``` 2039 + 2040 + **Step 4: Run test to verify it passes** 2041 + 2042 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 2043 + Expected: PASS 2044 + 2045 + **Step 5: Commit** 2046 + 2047 + ```bash 2048 + cd /Users/chadmiller/code/quickslice/lexicon_graphql 2049 + git add src/lexicon_graphql/validation/input.gleam test/validation/input_test.gleam 2050 + git commit -m "feat(validation): add input constraint validation module" 2051 + ``` 2052 + 2053 + --- 2054 + 2055 + ## Part 4: Server-Level Rate Limiting 2056 + 2057 + ### Task 16: Create ETS Rate Limiter Module 2058 + 2059 + **Files:** 2060 + - Create: `/Users/chadmiller/code/quickslice/server/src/rate_limiter.gleam` 2061 + - Test: `/Users/chadmiller/code/quickslice/server/test/rate_limiter_test.gleam` 2062 + 2063 + **Step 1: Write the failing test** 2064 + 2065 + ```gleam 2066 + // test/rate_limiter_test.gleam 2067 + import gleeunit/should 2068 + import rate_limiter 2069 + 2070 + pub fn check_rate_passes_under_limit_test() { 2071 + let assert Ok(_) = rate_limiter.start() 2072 + 2073 + // First request should pass 2074 + rate_limiter.check("192.168.1.1", 100) 2075 + |> should.be_ok() 2076 + 2077 + rate_limiter.stop() 2078 + } 2079 + 2080 + pub fn check_rate_fails_over_limit_test() { 2081 + let assert Ok(_) = rate_limiter.start() 2082 + 2083 + // Make 101 requests - last one should fail 2084 + let results = list.range(1, 101) 2085 + |> list.map(fn(_) { rate_limiter.check("192.168.1.1", 100) }) 2086 + 2087 + let last = list.last(results) 2088 + case last { 2089 + Ok(Error(rate_limiter.RateLimited(_, _))) -> Nil 2090 + _ -> should.fail() 2091 + } 2092 + 2093 + rate_limiter.stop() 2094 + } 2095 + 2096 + pub fn check_rate_different_keys_independent_test() { 2097 + let assert Ok(_) = rate_limiter.start() 2098 + 2099 + // IP 1 at limit 2100 + list.range(1, 100) |> list.each(fn(_) { rate_limiter.check("ip1", 100) }) 2101 + 2102 + // IP 2 should still pass 2103 + rate_limiter.check("ip2", 100) 2104 + |> should.be_ok() 2105 + 2106 + rate_limiter.stop() 2107 + } 2108 + ``` 2109 + 2110 + **Step 2: Run test to verify it fails** 2111 + 2112 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 2113 + Expected: FAIL with "module rate_limiter not found" 2114 + 2115 + **Step 3: Write minimal implementation** 2116 + 2117 + ```gleam 2118 + // src/rate_limiter.gleam 2119 + import gleam/erlang/atom 2120 + import gleam/erlang/process 2121 + import gleam/int 2122 + import gleam/result 2123 + 2124 + /// Rate limiter error 2125 + pub type RateLimitError { 2126 + RateLimited(count: Int, limit: Int) 2127 + NotStarted 2128 + } 2129 + 2130 + /// ETS table name 2131 + const table_name = "rate_limiter" 2132 + 2133 + /// Start the rate limiter (create ETS table) 2134 + pub fn start() -> Result(Nil, String) { 2135 + case create_ets_table() { 2136 + Ok(_) -> Ok(Nil) 2137 + Error(e) -> Error(e) 2138 + } 2139 + } 2140 + 2141 + /// Stop the rate limiter (delete ETS table) 2142 + pub fn stop() -> Result(Nil, String) { 2143 + case delete_ets_table() { 2144 + Ok(_) -> Ok(Nil) 2145 + Error(e) -> Error(e) 2146 + } 2147 + } 2148 + 2149 + /// Check if request is within rate limit 2150 + /// Uses sliding window counter with 1-minute buckets 2151 + pub fn check(key: String, limit: Int) -> Result(Nil, RateLimitError) { 2152 + let now = current_minute() 2153 + let count = increment_and_get(key, now) 2154 + 2155 + case count > limit { 2156 + True -> Error(RateLimited(count, limit)) 2157 + False -> Ok(Nil) 2158 + } 2159 + } 2160 + 2161 + /// Get current count for a key (for monitoring) 2162 + pub fn get_count(key: String) -> Int { 2163 + let now = current_minute() 2164 + get_counter(key, now) 2165 + } 2166 + 2167 + // FFI for ETS operations 2168 + @external(erlang, "rate_limiter_ffi", "create_table") 2169 + fn create_ets_table() -> Result(Nil, String) 2170 + 2171 + @external(erlang, "rate_limiter_ffi", "delete_table") 2172 + fn delete_ets_table() -> Result(Nil, String) 2173 + 2174 + @external(erlang, "rate_limiter_ffi", "increment_counter") 2175 + fn increment_and_get(key: String, minute: Int) -> Int 2176 + 2177 + @external(erlang, "rate_limiter_ffi", "get_counter") 2178 + fn get_counter(key: String, minute: Int) -> Int 2179 + 2180 + @external(erlang, "rate_limiter_ffi", "current_minute") 2181 + fn current_minute() -> Int 2182 + ``` 2183 + 2184 + Create FFI file: 2185 + 2186 + ```erlang 2187 + %% src/rate_limiter_ffi.erl 2188 + -module(rate_limiter_ffi). 2189 + -export([create_table/0, delete_table/0, increment_counter/2, get_counter/2, current_minute/0]). 2190 + 2191 + create_table() -> 2192 + try 2193 + ets:new(rate_limiter, [named_table, public, set, {write_concurrency, true}]), 2194 + {ok, nil} 2195 + catch 2196 + error:badarg -> {ok, nil} % Table already exists 2197 + end. 2198 + 2199 + delete_table() -> 2200 + try 2201 + ets:delete(rate_limiter), 2202 + {ok, nil} 2203 + catch 2204 + error:badarg -> {ok, nil} 2205 + end. 2206 + 2207 + increment_counter(Key, Minute) -> 2208 + FullKey = {Key, Minute}, 2209 + try 2210 + ets:update_counter(rate_limiter, FullKey, {2, 1}, {FullKey, 0}) 2211 + catch 2212 + error:badarg -> 1 2213 + end. 2214 + 2215 + get_counter(Key, Minute) -> 2216 + FullKey = {Key, Minute}, 2217 + case ets:lookup(rate_limiter, FullKey) of 2218 + [{_, Count}] -> Count; 2219 + [] -> 0 2220 + end. 2221 + 2222 + current_minute() -> 2223 + erlang:system_time(seconds) div 60. 2224 + ``` 2225 + 2226 + **Step 4: Run test to verify it passes** 2227 + 2228 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 2229 + Expected: PASS 2230 + 2231 + **Step 5: Commit** 2232 + 2233 + ```bash 2234 + cd /Users/chadmiller/code/quickslice/server 2235 + git add src/rate_limiter.gleam src/rate_limiter_ffi.erl test/rate_limiter_test.gleam 2236 + git commit -m "feat(server): add ETS-backed rate limiter" 2237 + ``` 2238 + 2239 + --- 2240 + 2241 + ### Task 17: Integrate Rate Limiting into GraphQL Handler 2242 + 2243 + **Files:** 2244 + - Modify: `/Users/chadmiller/code/quickslice/server/src/handlers/graphql.gleam` 2245 + - Test: Manual testing via curl 2246 + 2247 + **Step 1: Identify integration point** 2248 + 2249 + Read the current graphql handler to find where to add rate limiting check. 2250 + 2251 + **Step 2: Add rate limiting check** 2252 + 2253 + ```gleam 2254 + // In graphql.gleam handler, add rate limit check before processing 2255 + import rate_limiter 2256 + 2257 + pub fn handle(req: Request) -> Response { 2258 + // Extract client IP 2259 + let client_ip = get_client_ip(req) 2260 + 2261 + // Check rate limit (100 requests per minute) 2262 + case rate_limiter.check(client_ip, 100) { 2263 + Error(rate_limiter.RateLimited(count, limit)) -> { 2264 + wisp.response(429) 2265 + |> wisp.set_header("X-RateLimit-Limit", int.to_string(limit)) 2266 + |> wisp.set_header("X-RateLimit-Remaining", "0") 2267 + |> wisp.set_header("Retry-After", "60") 2268 + |> wisp.json_body(json.object([ 2269 + #("error", json.string("Rate limit exceeded")), 2270 + #("limit", json.int(limit)), 2271 + #("count", json.int(count)), 2272 + ])) 2273 + } 2274 + Ok(_) -> { 2275 + // Continue with normal handling 2276 + handle_graphql_request(req) 2277 + } 2278 + Error(rate_limiter.NotStarted) -> { 2279 + // Rate limiter not running, allow request 2280 + handle_graphql_request(req) 2281 + } 2282 + } 2283 + } 2284 + 2285 + fn get_client_ip(req: Request) -> String { 2286 + // Check X-Forwarded-For header first (for proxied requests) 2287 + case wisp.get_header(req, "x-forwarded-for") { 2288 + Some(ips) -> { 2289 + // Take first IP (client's original IP) 2290 + case string.split(ips, ",") { 2291 + [ip, ..] -> string.trim(ip) 2292 + [] -> "unknown" 2293 + } 2294 + } 2295 + None -> { 2296 + // Fall back to direct connection IP 2297 + case wisp.get_header(req, "x-real-ip") { 2298 + Some(ip) -> ip 2299 + None -> "unknown" 2300 + } 2301 + } 2302 + } 2303 + } 2304 + ``` 2305 + 2306 + **Step 3: Initialize rate limiter in server startup** 2307 + 2308 + Add to server.gleam main function: 2309 + 2310 + ```gleam 2311 + // In server startup 2312 + let assert Ok(_) = rate_limiter.start() 2313 + ``` 2314 + 2315 + **Step 4: Test manually** 2316 + 2317 + ```bash 2318 + # Start server 2319 + cd /Users/chadmiller/code/quickslice/server && gleam run 2320 + 2321 + # In another terminal, make rapid requests 2322 + for i in {1..105}; do 2323 + curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4000/graphql -d '{"query":"{ __typename }"}' 2324 + done | tail -10 2325 + # Should see 429 responses after 100 requests 2326 + ``` 2327 + 2328 + **Step 5: Commit** 2329 + 2330 + ```bash 2331 + cd /Users/chadmiller/code/quickslice/server 2332 + git add src/handlers/graphql.gleam src/server.gleam 2333 + git commit -m "feat(server): integrate rate limiting into GraphQL handler" 2334 + ``` 2335 + 2336 + --- 2337 + 2338 + ### Task 18: Add Query Size Limit Middleware 2339 + 2340 + **Files:** 2341 + - Modify: `/Users/chadmiller/code/quickslice/server/src/handlers/graphql.gleam` 2342 + 2343 + **Step 1: Add size check before parsing** 2344 + 2345 + ```gleam 2346 + // Add constant for max query size 2347 + const max_query_size = 100_000 // 100KB 2348 + 2349 + pub fn handle(req: Request) -> Response { 2350 + // Read body 2351 + let body = wisp.read_body(req) 2352 + 2353 + // Check size limit 2354 + case string.byte_size(body) > max_query_size { 2355 + True -> { 2356 + wisp.response(413) 2357 + |> wisp.json_body(json.object([ 2358 + #("error", json.string("Query too large")), 2359 + #("max_size", json.int(max_query_size)), 2360 + ])) 2361 + } 2362 + False -> { 2363 + // Continue with rate limiting and processing 2364 + check_rate_limit_and_process(req, body) 2365 + } 2366 + } 2367 + } 2368 + ``` 2369 + 2370 + **Step 2: Test manually** 2371 + 2372 + ```bash 2373 + # Create a large query file 2374 + python3 -c "print('{ ' + 'user { name } ' * 10000 + '}')" > /tmp/large_query.txt 2375 + wc -c /tmp/large_query.txt # Should be > 100KB 2376 + 2377 + # Try to send it 2378 + curl -X POST http://localhost:4000/graphql \ 2379 + -H "Content-Type: application/json" \ 2380 + -d @/tmp/large_query.txt 2381 + # Should get 413 response 2382 + ``` 2383 + 2384 + **Step 3: Commit** 2385 + 2386 + ```bash 2387 + cd /Users/chadmiller/code/quickslice/server 2388 + git add src/handlers/graphql.gleam 2389 + git commit -m "feat(server): add query size limit (100KB)" 2390 + ``` 2391 + 2392 + --- 2393 + 2394 + ## Part 5: Integration and Final Testing 2395 + 2396 + ### Task 19: Update Quickslice to Use Security Config 2397 + 2398 + **Files:** 2399 + - Modify: `/Users/chadmiller/code/quickslice/server/src/handlers/graphql.gleam` 2400 + 2401 + **Step 1: Import and configure security** 2402 + 2403 + ```gleam 2404 + import swell/security 2405 + 2406 + // Create security config for production use 2407 + fn get_security_config(ctx: schema.Context) -> security.SecurityConfig { 2408 + security.SecurityConfig( 2409 + max_depth: 10, 2410 + max_cost: 10_000, 2411 + default_list_cost: 25, 2412 + timeout_ms: 30_000, 2413 + allow_introspection: fn(c) { 2414 + // Allow introspection for authenticated users only 2415 + case c.data { 2416 + option.Some(_) -> True // Has auth context 2417 + option.None -> False 2418 + } 2419 + }, 2420 + batch_policy: security.Limited(5), 2421 + ) 2422 + } 2423 + ``` 2424 + 2425 + **Step 2: Use execute_with_security** 2426 + 2427 + Replace `executor.execute` calls with `executor.execute_with_security`: 2428 + 2429 + ```gleam 2430 + let security_config = get_security_config(ctx) 2431 + case executor.execute_with_security(query, schema, ctx, security_config) { 2432 + Ok(response) -> format_response(response) 2433 + Error(err) -> format_error(err) 2434 + } 2435 + ``` 2436 + 2437 + **Step 3: Commit** 2438 + 2439 + ```bash 2440 + cd /Users/chadmiller/code/quickslice/server 2441 + git add src/handlers/graphql.gleam 2442 + git commit -m "feat(server): enable GraphQL security validations in production" 2443 + ``` 2444 + 2445 + --- 2446 + 2447 + ### Task 20: Write Integration Tests 2448 + 2449 + **Files:** 2450 + - Create: `/Users/chadmiller/code/quickslice/server/test/security_integration_test.gleam` 2451 + 2452 + **Step 1: Write integration tests** 2453 + 2454 + ```gleam 2455 + // test/security_integration_test.gleam 2456 + import gleeunit/should 2457 + import gleam/http/request 2458 + import gleam/json 2459 + import server/test_helpers 2460 + 2461 + pub fn deep_query_rejected_test() { 2462 + let query = "{ a { b { c { d { e { f { g { h { i { j { k } } } } } } } } } } }" 2463 + 2464 + let response = test_helpers.graphql_request(query) 2465 + 2466 + response.status |> should.equal(200) 2467 + // Check error in response body 2468 + let assert Ok(body) = json.decode(response.body) 2469 + body |> json.field("errors") |> should.be_some() 2470 + } 2471 + 2472 + pub fn expensive_query_rejected_test() { 2473 + let query = "{ users(first: 1000) { posts(first: 100) { comments(first: 100) { text } } } }" 2474 + 2475 + let response = test_helpers.graphql_request(query) 2476 + 2477 + response.status |> should.equal(200) 2478 + let assert Ok(body) = json.decode(response.body) 2479 + body |> json.field("errors") |> should.be_some() 2480 + } 2481 + 2482 + pub fn introspection_requires_auth_test() { 2483 + let query = "{ __schema { types { name } } }" 2484 + 2485 + // Without auth 2486 + let response = test_helpers.graphql_request(query) 2487 + let assert Ok(body) = json.decode(response.body) 2488 + body |> json.field("errors") |> should.be_some() 2489 + 2490 + // With auth 2491 + let auth_response = test_helpers.graphql_request_with_auth(query, "test-token") 2492 + let assert Ok(auth_body) = json.decode(auth_response.body) 2493 + auth_body |> json.field("data") |> should.be_some() 2494 + } 2495 + 2496 + pub fn rate_limiting_works_test() { 2497 + // Make 101 requests quickly 2498 + let responses = list.range(1, 101) 2499 + |> list.map(fn(_) { test_helpers.graphql_request("{ __typename }") }) 2500 + 2501 + // At least one should be rate limited 2502 + let rate_limited = list.any(responses, fn(r) { r.status == 429 }) 2503 + rate_limited |> should.be_true() 2504 + } 2505 + ``` 2506 + 2507 + **Step 2: Run tests** 2508 + 2509 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 2510 + Expected: PASS 2511 + 2512 + **Step 3: Commit** 2513 + 2514 + ```bash 2515 + cd /Users/chadmiller/code/quickslice/server 2516 + git add test/security_integration_test.gleam 2517 + git commit -m "test(server): add GraphQL security integration tests" 2518 + ``` 2519 + 2520 + --- 2521 + 2522 + ### Task 21: Update Documentation 2523 + 2524 + **Files:** 2525 + - Create: `/Users/chadmiller/code/swell/docs/security.md` 2526 + - Create: `/Users/chadmiller/code/quickslice/lexicon_graphql/docs/validation.md` 2527 + 2528 + **Step 1: Write swell security docs** 2529 + 2530 + ```markdown 2531 + # Swell Security Configuration 2532 + 2533 + Swell provides built-in security features to protect your GraphQL API. 2534 + 2535 + ## Quick Start 2536 + 2537 + ```gleam 2538 + import swell/executor 2539 + import swell/security 2540 + 2541 + // Use production defaults 2542 + let config = security.production_config() 2543 + 2544 + // Or customize 2545 + let config = security.SecurityConfig( 2546 + max_depth: 10, 2547 + max_cost: 10_000, 2548 + default_list_cost: 25, 2549 + timeout_ms: 30_000, 2550 + allow_introspection: fn(ctx) { ctx.user != None }, 2551 + batch_policy: security.Limited(5), 2552 + ) 2553 + 2554 + // Execute with security 2555 + executor.execute_with_security(query, schema, ctx, config) 2556 + ``` 2557 + 2558 + ## Configuration Options 2559 + 2560 + | Option | Default (dev) | Default (prod) | Description | 2561 + |--------|---------------|----------------|-------------| 2562 + | max_depth | 10 | 10 | Maximum nesting depth | 2563 + | max_cost | 10,000 | 5,000 | Maximum query cost | 2564 + | default_list_cost | 25 | 25 | Cost multiplier for unbounded lists | 2565 + | timeout_ms | 30,000 | 15,000 | Execution timeout | 2566 + | allow_introspection | Always | Never | Callback for introspection access | 2567 + | batch_policy | Limited(10) | Limited(5) | Batch operation limit | 2568 + 2569 + ## Error Handling 2570 + 2571 + Security errors are returned as strings with corresponding codes: 2572 + 2573 + - `QUERY_TOO_DEEP` - Query exceeds max_depth 2574 + - `QUERY_TOO_EXPENSIVE` - Query exceeds max_cost 2575 + - `EXECUTION_TIMEOUT` - Query timed out 2576 + - `INTROSPECTION_DISABLED` - Introspection not allowed 2577 + - `BATCHING_DISABLED` - Batching not allowed 2578 + - `BATCH_TOO_LARGE` - Too many operations in batch 2579 + ``` 2580 + 2581 + **Step 2: Commit documentation** 2582 + 2583 + ```bash 2584 + cd /Users/chadmiller/code/swell 2585 + git add docs/security.md 2586 + git commit -m "docs: add security configuration guide" 2587 + ``` 2588 + 2589 + --- 2590 + 2591 + ## Summary 2592 + 2593 + This plan implements comprehensive GraphQL security hardening across three layers: 2594 + 2595 + 1. **Swell (executor)**: Query depth, cost analysis, introspection control, batching limits, timeouts 2596 + 2. **lexicon_graphql (schema)**: Custom scalars (AtUri, Did, Handle, DateTime), constraint validation 2597 + 3. **Quickslice server**: Rate limiting (ETS-backed), query size limits 2598 + 2599 + All changes are backwards-compatible with sensible defaults. Existing code works unchanged; stricter settings can be enabled for production.