···673673 path := strings.TrimPrefix(r.URL.Path, "/")
674674675675 parts := strings.SplitN(path, "/", 2)
676676- input := parts[0] // Could be DID or handle
676676+ input := parts[0]
677677678678- // Accept both DIDs and handles
679679- // DIDs: did:plc:*, did:web:*
680680- // Handles: tree.fail, ngerakines.me, etc.
678678+ // Quick validation: must be either a DID or a valid handle format
679679+ if !isValidDIDOrHandle(input) {
680680+ sendJSON(w, 404, map[string]string{"error": "not found"})
681681+ return
682682+ }
681683684684+ // Route to appropriate handler
682685 if len(parts) == 1 {
683686 s.handleDIDDocument(input)(w, r)
684687 } else if parts[1] == "data" {
···688691 } else {
689692 sendJSON(w, 404, map[string]string{"error": "not found"})
690693 }
694694+}
695695+696696+// isValidDIDOrHandle does quick format check before expensive resolution
697697+func isValidDIDOrHandle(input string) bool {
698698+ // Empty input
699699+ if input == "" {
700700+ return false
701701+ }
702702+703703+ // If it's a DID
704704+ if strings.HasPrefix(input, "did:") {
705705+ // Only accept did:plc: method (reject other methods at routing level)
706706+ if !strings.HasPrefix(input, "did:plc:") {
707707+ return false // Returns 404 for did:web:, did:key:, did:invalid:, etc
708708+ }
709709+710710+ // Accept any did:plc:* - let handler validate exact format
711711+ // This allows invalid formats to reach handler and get proper 400 errors
712712+ return true
713713+ }
714714+715715+ // Not a DID - validate as handle
716716+ // Must have at least one dot (domain.tld)
717717+ if !strings.Contains(input, ".") {
718718+ return false
719719+ }
720720+721721+ // Must not have invalid characters for a domain
722722+ // Simple check: alphanumeric, dots, hyphens only
723723+ for _, c := range input {
724724+ if !((c >= 'a' && c <= 'z') ||
725725+ (c >= 'A' && c <= 'Z') ||
726726+ (c >= '0' && c <= '9') ||
727727+ c == '.' || c == '-') {
728728+ return false
729729+ }
730730+ }
731731+732732+ // Basic length check (DNS max is 253)
733733+ if len(input) > 253 {
734734+ return false
735735+ }
736736+737737+ // Must not start or end with dot or hyphen
738738+ if strings.HasPrefix(input, ".") || strings.HasSuffix(input, ".") ||
739739+ strings.HasPrefix(input, "-") || strings.HasSuffix(input, "-") {
740740+ return false
741741+ }
742742+743743+ return true
691744}
692745693746func (s *Server) handleDIDDocument(input string) http.HandlerFunc {
+167-3
server/server_test.go
···378378 }
379379 })
380380381381+ t.Run("InvalidDIDMethod_Returns400", func(t *testing.T) {
382382+ // These now return 400 (validation error) instead of 404 (routing rejection)
383383+ wrongMethodDIDs := []string{
384384+ "did:invalid:format",
385385+ "did:web:example.com",
386386+ "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
387387+ }
388388+389389+ for _, did := range wrongMethodDIDs {
390390+ resp, err := http.Get(ts.URL + "/" + did + "/data")
391391+ if err != nil {
392392+ t.Fatalf("request failed: %v", err)
393393+ }
394394+ resp.Body.Close()
395395+396396+ // Now expect 400 (invalid DID format) or 404 (routing rejection)
397397+ if resp.StatusCode != 400 && resp.StatusCode != 404 {
398398+ t.Errorf("DID %s: expected 400 or 404, got %d", did, resp.StatusCode)
399399+ }
400400+ }
401401+ })
402402+403403+ t.Run("HandleLikePathWithoutResolver", func(t *testing.T) {
404404+ // Need to create a fresh manager without resolver for this test
405405+ tmpDir := t.TempDir()
406406+ config := bundle.DefaultConfig(tmpDir)
407407+ config.AutoInit = true
408408+ config.HandleResolverURL = "" // ← DISABLE resolver
409409+410410+ mgr, err := bundle.NewManager(config, nil)
411411+ if err != nil {
412412+ t.Fatalf("failed to create manager: %v", err)
413413+ }
414414+ defer mgr.Close()
415415+416416+ serverConfig := &server.Config{
417417+ Addr: ":8080",
418418+ EnableResolver: true,
419419+ Version: "test",
420420+ }
421421+422422+ srv := server.New(mgr, serverConfig)
423423+ ts := httptest.NewServer(srv.Handler())
424424+ defer ts.Close()
425425+426426+ // Now test handle resolution without resolver configured
427427+ resp, err := http.Get(ts.URL + "/tree.fail")
428428+ if err != nil {
429429+ t.Fatalf("request failed: %v", err)
430430+ }
431431+ body, _ := io.ReadAll(resp.Body)
432432+ resp.Body.Close()
433433+434434+ // Should get 400 (resolver not configured)
435435+ if resp.StatusCode != 400 {
436436+ t.Errorf("expected 400 (resolver not configured), got %d: %s",
437437+ resp.StatusCode, string(body))
438438+ return
439439+ }
440440+441441+ // Verify error message
442442+ var errResp map[string]string
443443+ json.Unmarshal(body, &errResp)
444444+445445+ if !strings.Contains(errResp["error"], "resolver") &&
446446+ !strings.Contains(errResp["hint"], "resolver") {
447447+ t.Errorf("expected resolver error, got: %v", errResp)
448448+ }
449449+ })
450450+451451+ t.Run("HandleResolutionWithIndex", func(t *testing.T) {
452452+ // The default setupTestServerWithResolver has resolver configured
453453+ // So this tests the normal flow: handle → DID → document
454454+455455+ resp, err := http.Get(ts.URL + "/tree.fail")
456456+ if err != nil {
457457+ t.Fatalf("request failed: %v", err)
458458+ }
459459+ body, _ := io.ReadAll(resp.Body)
460460+ resp.Body.Close()
461461+462462+ // Could be:
463463+ // - 500: No DID index (expected in test)
464464+ // - 404: DID not found in index
465465+ // - 200: Success (if test data includes this DID)
466466+467467+ switch resp.StatusCode {
468468+ case 500:
469469+ // No DID index - expected in test environment
470470+ var errResp map[string]string
471471+ json.Unmarshal(body, &errResp)
472472+ if !strings.Contains(errResp["error"], "DID index") {
473473+ t.Errorf("expected DID index error, got: %s", errResp["error"])
474474+ }
475475+ t.Log("Expected: no DID index configured")
476476+477477+ case 404:
478478+ // DID not found - also acceptable
479479+ t.Log("Expected: DID not found in index")
480480+481481+ case 200:
482482+ // Success - would need DID index + test data
483483+ var doc plcclient.DIDDocument
484484+ json.Unmarshal(body, &doc)
485485+ t.Logf("Success: resolved to %s", doc.ID)
486486+487487+ default:
488488+ t.Errorf("unexpected status: %d, body: %s", resp.StatusCode, string(body))
489489+ }
490490+491491+ // Verify we got handle resolution header
492492+ if resolvedHandle := resp.Header.Get("X-Handle-Resolved"); resolvedHandle != "" {
493493+ if resolvedHandle != "tree.fail" {
494494+ t.Errorf("wrong handle in header: %s", resolvedHandle)
495495+ }
496496+ t.Log("✓ Handle resolution header present")
497497+ }
498498+ })
499499+381500 t.Run("InvalidDIDMethod_Returns404", func(t *testing.T) {
382382- // DIDs with wrong method get 404 from routing (never reach validation)
501501+ // These should be rejected by routing (404) not validation (400)
383502 wrongMethodDIDs := []string{
384503 "did:invalid:format",
385504 "did:web:example.com",
386505 "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
387387- "notadid",
388506 }
389507390508 for _, did := range wrongMethodDIDs {
···394512 }
395513 resp.Body.Close()
396514397397- // Should get 404 (not a did:plc: path)
515515+ // With smart routing, these get 404 (not supported)
398516 if resp.StatusCode != 404 {
399517 t.Errorf("DID %s: expected 404 from routing, got %d", did, resp.StatusCode)
400518 }
519519+ }
520520+ })
521521+522522+ t.Run("NotADIDPath", func(t *testing.T) {
523523+ resp, err := http.Get(ts.URL + "/notadid")
524524+ if err != nil {
525525+ t.Fatalf("request failed: %v", err)
526526+ }
527527+ defer resp.Body.Close()
528528+529529+ // "notadid" has no dot, rejected by isValidDIDOrHandle
530530+ if resp.StatusCode != 404 {
531531+ t.Errorf("expected 404 for non-DID path, got %d", resp.StatusCode)
532532+ }
533533+ })
534534+535535+ t.Run("ValidHandleFormat", func(t *testing.T) {
536536+ // These should pass routing validation (have dots, valid chars)
537537+ validHandles := []string{
538538+ "user.bsky.social",
539539+ "tree.fail",
540540+ "example.com",
541541+ }
542542+543543+ for _, handle := range validHandles {
544544+ resp, err := http.Get(ts.URL + "/" + handle)
545545+ if err != nil {
546546+ t.Fatalf("request failed: %v", err)
547547+ }
548548+ resp.Body.Close()
549549+550550+ // Should NOT be 404 (routing accepts it)
551551+ // Will be 400 (no resolver), 500 (no index), or 404 (not found)
552552+ if resp.StatusCode == 404 {
553553+ body, _ := io.ReadAll(resp.Body)
554554+ // 404 is OK if it's "DID not found", not "route not found"
555555+ var errResp map[string]string
556556+ resp.Body = io.NopCloser(bytes.NewReader(body))
557557+ json.NewDecoder(resp.Body).Decode(&errResp)
558558+559559+ if errResp["error"] == "not found" && !strings.Contains(errResp["error"], "DID") {
560560+ t.Errorf("Handle %s: got routing 404, should be accepted", handle)
561561+ }
562562+ }
563563+564564+ t.Logf("Handle %s: status %d (400/500/404 all acceptable)", handle, resp.StatusCode)
401565 }
402566 })
403567