From dc9c9b86f73a67a45a80996716e593a0a76f35c4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 12 Sep 2026 19:05:47 +0000 Subject: [PATCH] Serve the OpenAccess descriptor at /.well-known/openaccess.json Spec: https://logicsrc.com/openaccess Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SWRffW4ifQPUrGXJtgYWMd --- cmd/agentbbs/main.go | 3 +++ internal/files/openaccess.json | 29 +++++++++++++++++++++++++++++ internal/files/web.go | 32 ++++++++++++++++++++++++++++++++ internal/files/web_test.go | 31 +++++++++++++++++++++++++++++++ setup.sh | 6 ++++++ 5 files changed, 101 insertions(+) create mode 100644 internal/files/openaccess.json diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index bd4a8e7..d76b3a9 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -272,6 +272,9 @@ func main() { mux.HandleFunc("/verify", a.handleVerify) mux.HandleFunc("/irc-auth", a.handleIRCAuth) // Ergo auth-script: members-only gate mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + // OpenAccess descriptor for the BBS host itself (Caddy proxies the + // well-known path here); the files host serves the same one. + mux.Handle(files.OpenAccessPath, files.OpenAccessHandler()) log.Info("verify endpoint listening", "addr", verifyAddr) srv := &http.Server{Addr: verifyAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} if err := srv.ListenAndServe(); err != nil { diff --git a/internal/files/openaccess.json b/internal/files/openaccess.json new file mode 100644 index 0000000..7bb7dfb --- /dev/null +++ b/internal/files/openaccess.json @@ -0,0 +1,29 @@ +{ + "openaccess": "0.1", + "name": "AgentBBS", + "url": "https://bbs.profullstack.com", + "operator": "https://logicsrc.com/.well-known/openprofile.md", + "redirect_uris": [ + "https://bbs.profullstack.com/api/v1/openaccess/callback" + ], + "jwks": { + "keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": "Vg7JJd0byYl3", + "x": "bck0ITevVM5tl-uMt-IAzuD_4DBuTpn9zlE0WKimdxw", + "alg": "EdDSA", + "use": "sig" + } + ] + }, + "scopes": {}, + "honours": [ + "profullstack.com/all-access" + ], + "webhooks": "https://bbs.profullstack.com/api/v1/openaccess/events", + "hubs": [ + "https://openaccess.logicsrc.com" + ] +} diff --git a/internal/files/web.go b/internal/files/web.go index 3874970..95c446e 100644 --- a/internal/files/web.go +++ b/internal/files/web.go @@ -2,6 +2,7 @@ package files import ( "crypto/rand" + _ "embed" "encoding/hex" "errors" "fmt" @@ -71,9 +72,40 @@ func (s *Service) WebHandler(cfg WebConfig) http.Handler { mux.HandleFunc("/mkdir", h.handleMkdir) mux.HandleFunc("/delete", h.handleDelete) mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + mux.Handle(OpenAccessPath, OpenAccessHandler()) return mux } +// OpenAccessPath is the well-known location of the OpenAccess descriptor +// (https://logicsrc.com/openaccess). Hubs such as openaccess.logicsrc.com fetch +// it to list the BBS and to link accounts with OAuth 2.1 + PKCE. +const OpenAccessPath = "/.well-known/openaccess.json" + +// openAccessDescriptor is the static descriptor served verbatim. It names the +// public signing key (JWKS), the redirect URI and the hubs the BBS trusts. +// +//go:embed openaccess.json +var openAccessDescriptor []byte + +// OpenAccessHandler serves the embedded OpenAccess descriptor as JSON with a +// short public cache. It needs no session: hubs fetch it anonymously. +func OpenAccessHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=300") + w.Header().Set("Content-Length", strconv.Itoa(len(openAccessDescriptor))) + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(openAccessDescriptor) + }) +} + // --- session helpers -------------------------------------------------------- func (h *webSrv) lookup(r *http.Request) (string, bool) { diff --git a/internal/files/web_test.go b/internal/files/web_test.go index e899ffd..82ab3d2 100644 --- a/internal/files/web_test.go +++ b/internal/files/web_test.go @@ -2,6 +2,7 @@ package files import ( "bytes" + "encoding/json" "io" "mime" "mime/multipart" @@ -394,3 +395,33 @@ func TestWebPublicReadOnlyByDefault(t *testing.T) { t.Fatalf("public upload: want redirect, got %d", rr.Code) } } + +func TestWebOpenAccessDescriptor(t *testing.T) { + h, _ := webTestHandler(t) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, OpenAccessPath, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("content-type: got %q", ct) + } + if cc := rr.Header().Get("Cache-Control"); cc != "public, max-age=300" { + t.Fatalf("cache-control: got %q", cc) + } + var doc struct { + OpenAccess string `json:"openaccess"` + URL string `json:"url"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &doc); err != nil { + t.Fatalf("descriptor is not JSON: %v", err) + } + if doc.OpenAccess == "" || doc.URL == "" { + t.Fatalf("descriptor missing openaccess/url: %s", rr.Body.String()) + } + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, OpenAccessPath, nil)) + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("POST: want 405, got %d", rr.Code) + } +} diff --git a/setup.sh b/setup.sh index 0ad1b1c..387fbb3 100755 --- a/setup.sh +++ b/setup.sh @@ -774,6 +774,12 @@ ${DOMAIN} { reverse_proxy http://${HTTP_ADDR} } + # OpenAccess descriptor (https://logicsrc.com/openaccess): lets hubs list + # the BBS and link accounts with OAuth 2.1 + PKCE. Static JSON, no auth. + handle /.well-known/openaccess.json { + reverse_proxy http://${HTTP_ADDR} + } + # IRC over WebSocket: Caddy terminates TLS and proxies to Ergo's loopback # WebSocket listener, so web clients hit wss://${DOMAIN}/irc and agents get a # WebSocket transport without exposing another public port. (No-op if IRC=0;