From 895ea63a5e4ceb51b970bbfc95aaf8005a9bb8b3 Mon Sep 17 00:00:00 2001 From: jiyuu-jin Date: Tue, 7 Oct 2025 17:44:02 -0400 Subject: [PATCH 1/2] Add x oauth to default tools and fix linting errors --- auth/client_test.go | 2 +- auth/server.go | 6 +- core/operations.go | 2 +- http/auth.go | 115 +++++++++++++---- http/http.go | 2 +- registry/default.go | 4 +- registry/default.json | 271 ++++++++++++++++++++++++++++++++++++++++ registry/oauth_scope.go | 34 ++++- 8 files changed, 400 insertions(+), 36 deletions(-) diff --git a/auth/client_test.go b/auth/client_test.go index e8b709da..80fff4d9 100644 --- a/auth/client_test.go +++ b/auth/client_test.go @@ -78,7 +78,7 @@ func TestOAuthClient_GetToken_Expired(t *testing.T) { // Since we don't have a real OAuth provider set up for refreshing, // the token refresh will fail, but we should still get the expired token - token, err := client.GetToken(ctx, "google", "sheets") + token, _ := client.GetToken(ctx, "google", "sheets") // We expect an error due to failed refresh, but the token should be returned if token != "expired-token" { t.Errorf("Expected token 'expired-token', got %s", token) diff --git a/auth/server.go b/auth/server.go index 5b09399a..4b5bfd17 100644 --- a/auth/server.go +++ b/auth/server.go @@ -436,13 +436,13 @@ func (t *TokenStore) Create(ctx context.Context, info oauth2.TokenInfo) error { Scope: info.GetScope(), Code: info.GetCode(), CodeCreateAt: info.GetCodeCreateAt(), - CodeExpiresIn: time.Duration(info.GetCodeExpiresIn()) * time.Second, + CodeExpiresIn: info.GetCodeExpiresIn() * time.Second, Access: info.GetAccess(), AccessCreateAt: info.GetAccessCreateAt(), - AccessExpiresIn: time.Duration(info.GetAccessExpiresIn()) * time.Second, + AccessExpiresIn: info.GetAccessExpiresIn() * time.Second, Refresh: info.GetRefresh(), RefreshCreateAt: info.GetRefreshCreateAt(), - RefreshExpiresIn: time.Duration(info.GetRefreshExpiresIn()) * time.Second, + RefreshExpiresIn: info.GetRefreshExpiresIn() * time.Second, } return t.store.SaveOAuthToken(ctx, token) diff --git a/core/operations.go b/core/operations.go index 87563e79..efe8026b 100644 --- a/core/operations.go +++ b/core/operations.go @@ -383,7 +383,7 @@ func handleInstallToolCLI(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to read from stdin: %w", err) } - result, err = InstallToolFromManifest(context.Background(), string(data)) + result, _ = InstallToolFromManifest(context.Background(), string(data)) } else if _, statErr := os.Stat(arg); statErr == nil { // It's a file result, err = InstallToolFromManifest(context.Background(), arg) diff --git a/http/auth.go b/http/auth.go index b348b6ac..873e46a9 100644 --- a/http/auth.go +++ b/http/auth.go @@ -3,6 +3,7 @@ package http import ( "context" "crypto/rand" + "crypto/sha256" "encoding/base64" "encoding/json" "fmt" @@ -25,7 +26,7 @@ import ( ) // setupOAuthServer creates and returns an OAuth server instance -func setupOAuthServer(cfg *config.Config, store storage.Storage) (*auth.OAuthConfig, *auth.OAuthServer) { +func setupOAuthServer(cfg *config.Config, store storage.Storage) *auth.OAuthServer { // Create OAuth server configuration oauthCfg := &auth.OAuthConfig{ Issuer: getOAuthIssuerURL(cfg), @@ -39,12 +40,12 @@ func setupOAuthServer(cfg *config.Config, store storage.Storage) (*auth.OAuthCon // Create OAuth server oauthServer := auth.NewOAuthServer(oauthCfg, store) - return oauthCfg, oauthServer + return oauthServer } // SetupOAuthHandlers adds OAuth 2.1 endpoints to the HTTP server func SetupOAuthHandlers(mux *http.ServeMux, cfg *config.Config, store storage.Storage) error { - _, oauthServer := setupOAuthServer(cfg, store) + oauthServer := setupOAuthServer(cfg, store) // Register OAuth endpoints mux.HandleFunc("/.well-known/oauth-authorization-server", oauthServer.HandleMetadataDiscovery) @@ -109,7 +110,6 @@ func setupMCPRoutes(mux *http.ServeMux, store storage.Storage, oauthServer *auth utils.Debug("MCP request: method=%s, id=%v", method, id) w.Header().Set("Content-Type", "application/json") - ctx := r.Context() // Handle MCP protocol methods switch method { @@ -193,7 +193,7 @@ func setupMCPRoutes(mux *http.ServeMux, store storage.Storage, oauthServer *auth // Call the tool handler (this will be type-safe based on the generated handlers) // For now, we'll handle the most common case - operations that take structured args - result, err := callToolHandler(ctx, handler, toolArgs) + result, err := callToolHandler(handler, toolArgs) if err != nil { utils.Warn("MCP tool execution failed: %v", err) response := map[string]interface{}{ @@ -245,7 +245,7 @@ func setupMCPRoutes(mux *http.ServeMux, store storage.Storage, oauthServer *auth } // callToolHandler dynamically calls an MCP tool handler with the provided arguments -func callToolHandler(ctx context.Context, handler interface{}, args map[string]interface{}) (interface{}, error) { +func callToolHandler(handler interface{}, args map[string]interface{}) (interface{}, error) { handlerValue := reflect.ValueOf(handler) handlerType := handlerValue.Type() @@ -487,7 +487,7 @@ func (a *AuthMiddleware) Middleware(next http.Handler) http.Handler { } // unauthorized sends an HTTP 401 Unauthorized response with OAuth challenge -func (a *AuthMiddleware) unauthorized(w http.ResponseWriter, r *http.Request, message string) { +func (a *AuthMiddleware) unauthorized(w http.ResponseWriter, _ *http.Request, message string) { // According to MCP spec, when authorization is required but not provided, // servers should respond with HTTP 401 Unauthorized @@ -552,7 +552,7 @@ func (a *AuthMiddleware) OptionalMiddleware(next http.Handler) http.Handler { }) } -// enforceHTTPS ensures OAuth endpoints are accessed over HTTPS (except localhost for development) +// enforceHTTPS ensures OAuth endpoints are accessed over HTTPS (except localhost and ngrok for development) func enforceHTTPS(w http.ResponseWriter, r *http.Request) bool { // Allow HTTP for localhost development if r.Host == "localhost:8080" || r.Host == "127.0.0.1:8080" || @@ -560,6 +560,11 @@ func enforceHTTPS(w http.ResponseWriter, r *http.Request) bool { return true } + // Allow HTTP for ngrok tunnels (development) + if strings.Contains(r.Host, ".ngrok-free.dev") || strings.Contains(r.Host, ".ngrok.io") { + return true + } + if r.TLS == nil { http.Error(w, "HTTPS required for OAuth endpoints", http.StatusForbidden) return false @@ -583,11 +588,29 @@ type WebOAuthHandler struct { // OAuthAuthState tracks the state of an OAuth authorization flow type OAuthAuthState struct { - Provider string - Integration string - UserID string // From MCP client authentication - CreatedAt time.Time - ExpiresAt time.Time + Provider string + Integration string + UserID string // From MCP client authentication + CreatedAt time.Time + ExpiresAt time.Time + CodeVerifier string // PKCE code verifier + CodeChallenge string // PKCE code challenge +} + +// generatePKCEChallenge generates a PKCE code verifier and challenge +func generatePKCEChallenge() (verifier, challenge string, err error) { + // Generate a random code verifier (43-128 characters) + verifierBytes := make([]byte, 32) + if _, err := rand.Read(verifierBytes); err != nil { + return "", "", err + } + verifier = base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(verifierBytes) + + // Generate code challenge using SHA256 + hash := sha256.Sum256([]byte(verifier)) + challenge = base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:]) + + return verifier, challenge, nil } // NewWebOAuthHandler creates a new web OAuth handler @@ -794,9 +817,17 @@ func (h *WebOAuthHandler) HandleOAuthAuthorize(w http.ResponseWriter, r *http.Re rand.Read(stateBytes) state := base64.URLEncoding.EncodeToString(stateBytes) + // Generate PKCE challenge for X OAuth 2.0 (required by X) + codeVerifier, codeChallenge, err := generatePKCEChallenge() + if err != nil { + utils.Error("Failed to generate PKCE challenge: %v", err) + http.Error(w, "Failed to generate PKCE challenge", http.StatusInternalServerError) + return + } + // Get user ID from session or create anonymous session session, _ := GetSessionFromRequest(r) - userID := "anonymous" + var userID string if session != nil { userID = session.UserID } else { @@ -810,13 +841,15 @@ func (h *WebOAuthHandler) HandleOAuthAuthorize(w http.ResponseWriter, r *http.Re userID = session.UserID } - // Store auth state + // Store auth state with PKCE parameters h.authStates[state] = &OAuthAuthState{ - Provider: providerName, - Integration: integration, - UserID: userID, - CreatedAt: time.Now(), - ExpiresAt: time.Now().Add(10 * time.Minute), // 10 minute expiry + Provider: providerName, + Integration: integration, + UserID: userID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(10 * time.Minute), // 10 minute expiry + CodeVerifier: codeVerifier, + CodeChallenge: codeChallenge, } // Build authorization URL @@ -827,12 +860,24 @@ func (h *WebOAuthHandler) HandleOAuthAuthorize(w http.ResponseWriter, r *http.Re return } + // Dynamically determine the redirect URI based on the request + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + redirectURI := fmt.Sprintf("%s://%s/oauth/callback", scheme, r.Host) + query := authURL.Query() query.Set("client_id", provider.ClientID) - query.Set("redirect_uri", h.baseURL+"/oauth/callback") + query.Set("redirect_uri", redirectURI) query.Set("scope", strings.Join(registry.ScopesToStrings(provider.Scopes), " ")) query.Set("response_type", "code") query.Set("state", state) + + // Add PKCE parameters (required by X OAuth 2.0) + query.Set("code_challenge", codeChallenge) + query.Set("code_challenge_method", "S256") + authURL.RawQuery = query.Encode() // Prepare template data @@ -894,15 +939,35 @@ func (h *WebOAuthHandler) HandleOAuthCallback(w http.ResponseWriter, r *http.Req // Exchange code for tokens tokenURL := provider.TokenURL + + // Dynamically determine the redirect URI based on the request (must match authorization) + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + redirectURI := fmt.Sprintf("%s://%s/oauth/callback", scheme, r.Host) + data := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, - "redirect_uri": {h.baseURL + "/oauth/callback"}, - "client_id": {provider.ClientID}, - "client_secret": {provider.ClientSecret}, + "redirect_uri": {redirectURI}, + "code_verifier": {authState.CodeVerifier}, // PKCE code verifier } - resp, err := http.PostForm(tokenURL, data) + // Create HTTP request with Basic Auth for confidential clients (X OAuth 2.0 requirement) + req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode())) + if err != nil { + utils.Error("Failed to create token request: %v", err) + http.Error(w, "Failed to create token request", http.StatusInternalServerError) + return + } + + // Set Basic Auth header with client credentials + req.SetBasicAuth(provider.ClientID, provider.ClientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) if err != nil { utils.Error("Failed to exchange code for tokens") http.Error(w, "Failed to exchange code for tokens", http.StatusInternalServerError) diff --git a/http/http.go b/http/http.go index b42ce0ec..74836744 100644 --- a/http/http.go +++ b/http/http.go @@ -99,7 +99,7 @@ func NewHandler(cfg *config.Config) (http.Handler, func(), error) { // Setup OAuth endpoints only if OAuth is enabled var oauthServer *auth.OAuthServer if cfg.OAuth != nil && cfg.OAuth.Enabled { - _, oauthServer = setupOAuthServer(cfg, store) + oauthServer = setupOAuthServer(cfg, store) if err := SetupOAuthHandlers(mux, cfg, store); err != nil { sessionStore.Close() baseCleanup() diff --git a/registry/default.go b/registry/default.go index 3d380c5c..cd8eeda9 100644 --- a/registry/default.go +++ b/registry/default.go @@ -82,9 +82,7 @@ func (d *DefaultRegistry) GetOAuthProvider(ctx context.Context, name string) (*R for _, provider := range providers { if provider.Name == name { // Expand environment variables in the provider configuration - expandedProvider := provider - expandedProvider.ClientID = os.ExpandEnv(provider.ClientID) - expandedProvider.ClientSecret = os.ExpandEnv(provider.ClientSecret) + expandedProvider := expandOAuthProviderEnvVars(provider) return &expandedProvider, nil } } diff --git a/registry/default.json b/registry/default.json index 17f3d9bd..caeb532a 100644 --- a/registry/default.json +++ b/registry/default.json @@ -847,5 +847,276 @@ "Authorization": "$oauth:github:default", "Content-Type": "application/json" } + }, + { + "type": "oauth_provider", + "name": "x", + "display_name": "X (Twitter)", + "icon": "🐦", + "authorization_url": "https://twitter.com/i/oauth2/authorize", + "token_url": "https://api.twitter.com/2/oauth2/token", + "client_id": "${X_CLIENT_ID}", + "client_secret": "${X_CLIENT_SECRET}", + "scopes": [ + "users.read", + "tweet.read", + "tweet.write", + "tweet.moderate.write", + "users.email", + "follows.read", + "follows.write", + "offline.access", + "space.read", + "mute.read", + "mute.write", + "like.read", + "like.write", + "list.read", + "list.write", + "block.read", + "block.write", + "bookmark.read", + "bookmark.write", + "media.write" + ], + "required_scopes": [ + "users.read" + ], + "scope_descriptions": { + "tweet.read": "All the Tweets you can view, including Tweets from protected accounts", + "tweet.write": "Tweet and Retweet for you", + "tweet.moderate.write": "Hide and unhide replies to your Tweets", + "users.email": "Email from an authenticated user", + "users.read": "Any account you can view, including protected accounts", + "follows.read": "People who follow you and people who you follow", + "follows.write": "Follow and unfollow people for you", + "offline.access": "Stay connected to your account until you revoke access", + "space.read": "All the Spaces you can view", + "mute.read": "Accounts you've muted", + "mute.write": "Mute and unmute accounts for you", + "like.read": "Tweets you've liked and likes you can view", + "like.write": "Like and un-like Tweets for you", + "list.read": "Lists, list members, and list followers of lists you've created or are a member of, including private lists", + "list.write": "Create and manage Lists for you", + "block.read": "Accounts you've blocked", + "block.write": "Block and unblock accounts for you", + "bookmark.read": "Get Bookmarked Tweets from an authenticated user", + "bookmark.write": "Bookmark and remove Bookmarks from Tweets", + "media.write": "Upload media" + } + }, + { + "type": "tool", + "name": "x.user.me", + "description": "Get the authenticated user's profile information", + "kind": "task", + "version": "1.0.0", + "registry": "default", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "user.fields": { + "type": "string", + "description": "Comma-separated list of user fields to include", + "default": "id,name,username,public_metrics,verified,description" + } + } + }, + "endpoint": "https://api.twitter.com/2/users/me", + "method": "GET", + "headers": { + "Authorization": "$oauth:x:default", + "Content-Type": "application/json" + } + }, + { + "type": "tool", + "name": "x.tweets.timeline", + "description": "Get tweets from the authenticated user's home timeline", + "kind": "task", + "version": "1.0.0", + "registry": "default", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "max_results": { + "type": "integer", + "description": "Maximum number of tweets to return (5-100)", + "minimum": 5, + "maximum": 100, + "default": 10 + }, + "tweet.fields": { + "type": "string", + "description": "Comma-separated list of tweet fields to include", + "default": "id,text,author_id,created_at,public_metrics,context_annotations" + }, + "user.fields": { + "type": "string", + "description": "Comma-separated list of user fields to include", + "default": "id,name,username,verified" + }, + "expansions": { + "type": "string", + "description": "Comma-separated list of expansions", + "default": "author_id" + } + } + }, + "endpoint": "https://api.twitter.com/2/users/me/timelines/reverse_chronological", + "method": "GET", + "headers": { + "Authorization": "$oauth:x:default", + "Content-Type": "application/json" + } + }, + { + "type": "tool", + "name": "x.tweets.post", + "description": "Post a new tweet to X (Twitter)", + "kind": "task", + "version": "1.0.0", + "registry": "default", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "text" + ], + "properties": { + "text": { + "type": "string", + "description": "The text content of the tweet", + "maxLength": 280 + }, + "reply": { + "type": "object", + "description": "Tweet to reply to", + "properties": { + "in_reply_to_tweet_id": { + "type": "string", + "description": "ID of the tweet to reply to" + } + } + }, + "media": { + "type": "object", + "description": "Media to attach to the tweet", + "properties": { + "media_ids": { + "type": "array", + "description": "Array of media IDs to attach", + "items": { + "type": "string" + } + } + } + }, + "poll": { + "type": "object", + "description": "Poll to include in the tweet", + "properties": { + "options": { + "type": "array", + "description": "Poll options (2-4 options)", + "items": { + "type": "string" + }, + "minItems": 2, + "maxItems": 4 + }, + "duration_minutes": { + "type": "integer", + "description": "Poll duration in minutes (5-10080)", + "minimum": 5, + "maximum": 10080, + "default": 1440 + } + } + } + } + }, + "endpoint": "https://api.twitter.com/2/tweets", + "method": "POST", + "headers": { + "Authorization": "$oauth:x:default", + "Content-Type": "application/json" + } + }, + { + "type": "tool", + "name": "x.tweets.user_timeline", + "description": "Get tweets from a specific user's timeline", + "kind": "task", + "version": "1.0.0", + "registry": "default", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "user_id" + ], + "properties": { + "user_id": { + "type": "string", + "description": "The ID of the user whose tweets to retrieve" + }, + "max_results": { + "type": "integer", + "description": "Maximum number of tweets to return (5-100)", + "minimum": 5, + "maximum": 100, + "default": 10 + }, + "tweet.fields": { + "type": "string", + "description": "Comma-separated list of tweet fields to include", + "default": "id,text,author_id,created_at,public_metrics,context_annotations" + }, + "exclude": { + "type": "string", + "description": "Comma-separated list of tweet types to exclude", + "enum": [ + "retweets", + "replies" + ] + } + } + }, + "endpoint": "https://api.twitter.com/2/users/{user_id}/tweets", + "method": "GET", + "headers": { + "Authorization": "$oauth:x:default", + "Content-Type": "application/json" + } + }, + { + "type": "tool", + "name": "x.tweets.like", + "description": "Like a tweet", + "kind": "task", + "version": "1.0.0", + "registry": "default", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "tweet_id" + ], + "properties": { + "tweet_id": { + "type": "string", + "description": "The ID of the tweet to like" + } + } + }, + "endpoint": "https://api.twitter.com/2/users/me/likes", + "method": "POST", + "headers": { + "Authorization": "$oauth:x:default", + "Content-Type": "application/json" + } } ] \ No newline at end of file diff --git a/registry/oauth_scope.go b/registry/oauth_scope.go index e3bad60f..a7a01e82 100644 --- a/registry/oauth_scope.go +++ b/registry/oauth_scope.go @@ -1,6 +1,9 @@ package registry -import "strings" +import ( + "strings" + "unicode" +) // OAuthScope represents an OAuth scope with automatic string formatting type OAuthScope string @@ -14,11 +17,38 @@ func (s OAuthScope) String() string { // Remove common prefixes and make it more readable lastPart = strings.ReplaceAll(lastPart, "_", " ") lastPart = strings.ReplaceAll(lastPart, ".", " ") - return strings.Title(lastPart) + return toTitle(lastPart) } return string(s) } +// toTitle is a replacement for the deprecated strings.Title +func toTitle(s string) string { + if s == "" { + return s + } + + runes := []rune(s) + result := make([]rune, len(runes)) + + // Capitalize first letter and letters after spaces + capitalizeNext := true + for i, r := range runes { + if capitalizeNext && unicode.IsLetter(r) { + result[i] = unicode.ToUpper(r) + capitalizeNext = false + } else { + result[i] = unicode.ToLower(r) + } + + if unicode.IsSpace(r) { + capitalizeNext = true + } + } + + return string(result) +} + // Raw returns the original scope string without formatting func (s OAuthScope) Raw() string { return string(s) From 97e974e7653e7f9dd327c96585782f79b51fd631 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Oct 2025 21:57:07 +0000 Subject: [PATCH 2/2] Refactor: Move toTitle to utils package Co-authored-by: alec --- registry/oauth_scope.go | 32 +++----------------------------- utils/helpers.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/registry/oauth_scope.go b/registry/oauth_scope.go index a7a01e82..64f78de2 100644 --- a/registry/oauth_scope.go +++ b/registry/oauth_scope.go @@ -2,7 +2,8 @@ package registry import ( "strings" - "unicode" + + "github.com/beemflow/beemflow/utils" ) // OAuthScope represents an OAuth scope with automatic string formatting @@ -17,38 +18,11 @@ func (s OAuthScope) String() string { // Remove common prefixes and make it more readable lastPart = strings.ReplaceAll(lastPart, "_", " ") lastPart = strings.ReplaceAll(lastPart, ".", " ") - return toTitle(lastPart) + return utils.ToTitle(lastPart) } return string(s) } -// toTitle is a replacement for the deprecated strings.Title -func toTitle(s string) string { - if s == "" { - return s - } - - runes := []rune(s) - result := make([]rune, len(runes)) - - // Capitalize first letter and letters after spaces - capitalizeNext := true - for i, r := range runes { - if capitalizeNext && unicode.IsLetter(r) { - result[i] = unicode.ToUpper(r) - capitalizeNext = false - } else { - result[i] = unicode.ToLower(r) - } - - if unicode.IsSpace(r) { - capitalizeNext = true - } - } - - return string(result) -} - // Raw returns the original scope string without formatting func (s OAuthScope) Raw() string { return string(s) diff --git a/utils/helpers.go b/utils/helpers.go index 130b8662..5d02bcae 100644 --- a/utils/helpers.go +++ b/utils/helpers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "unicode" "github.com/beemflow/beemflow/constants" ) @@ -174,6 +175,38 @@ func ValidateOneOf(fieldName string, value string, allowed []string) error { return Errorf("field '%s' must be one of %v, got '%s'", fieldName, allowed, value) } +// ============================================================================ +// STANDARDIZED STRING HELPERS +// ============================================================================ + +// ToTitle is a replacement for the deprecated strings.Title function +// It capitalizes the first letter and letters after spaces +func ToTitle(s string) string { + if s == "" { + return s + } + + runes := []rune(s) + result := make([]rune, len(runes)) + + // Capitalize first letter and letters after spaces + capitalizeNext := true + for i, r := range runes { + if capitalizeNext && unicode.IsLetter(r) { + result[i] = unicode.ToUpper(r) + capitalizeNext = false + } else { + result[i] = unicode.ToLower(r) + } + + if unicode.IsSpace(r) { + capitalizeNext = true + } + } + + return string(result) +} + // ============================================================================ // STANDARDIZED CONTEXT HELPERS // ============================================================================