···673 path := strings.TrimPrefix(r.URL.Path, "/")
674675 parts := strings.SplitN(path, "/", 2)
676- input := parts[0] // Could be DID or handle
677678- // Accept both DIDs and handles
679- // DIDs: did:plc:*, did:web:*
680- // Handles: tree.fail, ngerakines.me, etc.
006810682 if len(parts) == 1 {
683 s.handleDIDDocument(input)(w, r)
684 } else if parts[1] == "data" {
···688 } else {
689 sendJSON(w, 404, map[string]string{"error": "not found"})
690 }
00000000000000000000000000000000000000000000000000691}
692693func (s *Server) handleDIDDocument(input string) http.HandlerFunc {
···673 path := strings.TrimPrefix(r.URL.Path, "/")
674675 parts := strings.SplitN(path, "/", 2)
676+ input := parts[0]
677678+ // Quick validation: must be either a DID or a valid handle format
679+ if !isValidDIDOrHandle(input) {
680+ sendJSON(w, 404, map[string]string{"error": "not found"})
681+ return
682+ }
683684+ // Route to appropriate handler
685 if len(parts) == 1 {
686 s.handleDIDDocument(input)(w, r)
687 } else if parts[1] == "data" {
···691 } else {
692 sendJSON(w, 404, map[string]string{"error": "not found"})
693 }
694+}
695+696+// isValidDIDOrHandle does quick format check before expensive resolution
697+func isValidDIDOrHandle(input string) bool {
698+ // Empty input
699+ if input == "" {
700+ return false
701+ }
702+703+ // If it's a DID
704+ if strings.HasPrefix(input, "did:") {
705+ // Only accept did:plc: method (reject other methods at routing level)
706+ if !strings.HasPrefix(input, "did:plc:") {
707+ return false // Returns 404 for did:web:, did:key:, did:invalid:, etc
708+ }
709+710+ // Accept any did:plc:* - let handler validate exact format
711+ // This allows invalid formats to reach handler and get proper 400 errors
712+ return true
713+ }
714+715+ // Not a DID - validate as handle
716+ // Must have at least one dot (domain.tld)
717+ if !strings.Contains(input, ".") {
718+ return false
719+ }
720+721+ // Must not have invalid characters for a domain
722+ // Simple check: alphanumeric, dots, hyphens only
723+ for _, c := range input {
724+ if !((c >= 'a' && c <= 'z') ||
725+ (c >= 'A' && c <= 'Z') ||
726+ (c >= '0' && c <= '9') ||
727+ c == '.' || c == '-') {
728+ return false
729+ }
730+ }
731+732+ // Basic length check (DNS max is 253)
733+ if len(input) > 253 {
734+ return false
735+ }
736+737+ // Must not start or end with dot or hyphen
738+ if strings.HasPrefix(input, ".") || strings.HasSuffix(input, ".") ||
739+ strings.HasPrefix(input, "-") || strings.HasSuffix(input, "-") {
740+ return false
741+ }
742+743+ return true
744}
745746func (s *Server) handleDIDDocument(input string) http.HandlerFunc {
+167-3
server/server_test.go
···378 }
379 })
38000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000381 t.Run("InvalidDIDMethod_Returns404", func(t *testing.T) {
382- // DIDs with wrong method get 404 from routing (never reach validation)
383 wrongMethodDIDs := []string{
384 "did:invalid:format",
385 "did:web:example.com",
386 "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
387- "notadid",
388 }
389390 for _, did := range wrongMethodDIDs {
···394 }
395 resp.Body.Close()
396397- // Should get 404 (not a did:plc: path)
398 if resp.StatusCode != 404 {
399 t.Errorf("DID %s: expected 404 from routing, got %d", did, resp.StatusCode)
400 }
0000000000000000000000000000000000000000000000401 }
402 })
403
···378 }
379 })
380381+ t.Run("InvalidDIDMethod_Returns400", func(t *testing.T) {
382+ // These now return 400 (validation error) instead of 404 (routing rejection)
383+ wrongMethodDIDs := []string{
384+ "did:invalid:format",
385+ "did:web:example.com",
386+ "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
387+ }
388+389+ for _, did := range wrongMethodDIDs {
390+ resp, err := http.Get(ts.URL + "/" + did + "/data")
391+ if err != nil {
392+ t.Fatalf("request failed: %v", err)
393+ }
394+ resp.Body.Close()
395+396+ // Now expect 400 (invalid DID format) or 404 (routing rejection)
397+ if resp.StatusCode != 400 && resp.StatusCode != 404 {
398+ t.Errorf("DID %s: expected 400 or 404, got %d", did, resp.StatusCode)
399+ }
400+ }
401+ })
402+403+ t.Run("HandleLikePathWithoutResolver", func(t *testing.T) {
404+ // Need to create a fresh manager without resolver for this test
405+ tmpDir := t.TempDir()
406+ config := bundle.DefaultConfig(tmpDir)
407+ config.AutoInit = true
408+ config.HandleResolverURL = "" // ← DISABLE resolver
409+410+ mgr, err := bundle.NewManager(config, nil)
411+ if err != nil {
412+ t.Fatalf("failed to create manager: %v", err)
413+ }
414+ defer mgr.Close()
415+416+ serverConfig := &server.Config{
417+ Addr: ":8080",
418+ EnableResolver: true,
419+ Version: "test",
420+ }
421+422+ srv := server.New(mgr, serverConfig)
423+ ts := httptest.NewServer(srv.Handler())
424+ defer ts.Close()
425+426+ // Now test handle resolution without resolver configured
427+ resp, err := http.Get(ts.URL + "/tree.fail")
428+ if err != nil {
429+ t.Fatalf("request failed: %v", err)
430+ }
431+ body, _ := io.ReadAll(resp.Body)
432+ resp.Body.Close()
433+434+ // Should get 400 (resolver not configured)
435+ if resp.StatusCode != 400 {
436+ t.Errorf("expected 400 (resolver not configured), got %d: %s",
437+ resp.StatusCode, string(body))
438+ return
439+ }
440+441+ // Verify error message
442+ var errResp map[string]string
443+ json.Unmarshal(body, &errResp)
444+445+ if !strings.Contains(errResp["error"], "resolver") &&
446+ !strings.Contains(errResp["hint"], "resolver") {
447+ t.Errorf("expected resolver error, got: %v", errResp)
448+ }
449+ })
450+451+ t.Run("HandleResolutionWithIndex", func(t *testing.T) {
452+ // The default setupTestServerWithResolver has resolver configured
453+ // So this tests the normal flow: handle → DID → document
454+455+ resp, err := http.Get(ts.URL + "/tree.fail")
456+ if err != nil {
457+ t.Fatalf("request failed: %v", err)
458+ }
459+ body, _ := io.ReadAll(resp.Body)
460+ resp.Body.Close()
461+462+ // Could be:
463+ // - 500: No DID index (expected in test)
464+ // - 404: DID not found in index
465+ // - 200: Success (if test data includes this DID)
466+467+ switch resp.StatusCode {
468+ case 500:
469+ // No DID index - expected in test environment
470+ var errResp map[string]string
471+ json.Unmarshal(body, &errResp)
472+ if !strings.Contains(errResp["error"], "DID index") {
473+ t.Errorf("expected DID index error, got: %s", errResp["error"])
474+ }
475+ t.Log("Expected: no DID index configured")
476+477+ case 404:
478+ // DID not found - also acceptable
479+ t.Log("Expected: DID not found in index")
480+481+ case 200:
482+ // Success - would need DID index + test data
483+ var doc plcclient.DIDDocument
484+ json.Unmarshal(body, &doc)
485+ t.Logf("Success: resolved to %s", doc.ID)
486+487+ default:
488+ t.Errorf("unexpected status: %d, body: %s", resp.StatusCode, string(body))
489+ }
490+491+ // Verify we got handle resolution header
492+ if resolvedHandle := resp.Header.Get("X-Handle-Resolved"); resolvedHandle != "" {
493+ if resolvedHandle != "tree.fail" {
494+ t.Errorf("wrong handle in header: %s", resolvedHandle)
495+ }
496+ t.Log("✓ Handle resolution header present")
497+ }
498+ })
499+500 t.Run("InvalidDIDMethod_Returns404", func(t *testing.T) {
501+ // These should be rejected by routing (404) not validation (400)
502 wrongMethodDIDs := []string{
503 "did:invalid:format",
504 "did:web:example.com",
505 "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
0506 }
507508 for _, did := range wrongMethodDIDs {
···512 }
513 resp.Body.Close()
514515+ // With smart routing, these get 404 (not supported)
516 if resp.StatusCode != 404 {
517 t.Errorf("DID %s: expected 404 from routing, got %d", did, resp.StatusCode)
518 }
519+ }
520+ })
521+522+ t.Run("NotADIDPath", func(t *testing.T) {
523+ resp, err := http.Get(ts.URL + "/notadid")
524+ if err != nil {
525+ t.Fatalf("request failed: %v", err)
526+ }
527+ defer resp.Body.Close()
528+529+ // "notadid" has no dot, rejected by isValidDIDOrHandle
530+ if resp.StatusCode != 404 {
531+ t.Errorf("expected 404 for non-DID path, got %d", resp.StatusCode)
532+ }
533+ })
534+535+ t.Run("ValidHandleFormat", func(t *testing.T) {
536+ // These should pass routing validation (have dots, valid chars)
537+ validHandles := []string{
538+ "user.bsky.social",
539+ "tree.fail",
540+ "example.com",
541+ }
542+543+ for _, handle := range validHandles {
544+ resp, err := http.Get(ts.URL + "/" + handle)
545+ if err != nil {
546+ t.Fatalf("request failed: %v", err)
547+ }
548+ resp.Body.Close()
549+550+ // Should NOT be 404 (routing accepts it)
551+ // Will be 400 (no resolver), 500 (no index), or 404 (not found)
552+ if resp.StatusCode == 404 {
553+ body, _ := io.ReadAll(resp.Body)
554+ // 404 is OK if it's "DID not found", not "route not found"
555+ var errResp map[string]string
556+ resp.Body = io.NopCloser(bytes.NewReader(body))
557+ json.NewDecoder(resp.Body).Decode(&errResp)
558+559+ if errResp["error"] == "not found" && !strings.Contains(errResp["error"], "DID") {
560+ t.Errorf("Handle %s: got routing 404, should be accepted", handle)
561+ }
562+ }
563+564+ t.Logf("Handle %s: status %d (400/500/404 all acceptable)", handle, resp.StatusCode)
565 }
566 })
567