package handlers // ArtifactsHandler exposes the Cloudflare Artifacts demo integration. // // Routes (all behind WorkspaceAuth middleware): // // POST /workspaces/:id/artifacts — attach a CF Artifacts repo to this workspace // GET /workspaces/:id/artifacts — get the linked repo info // POST /workspaces/:id/artifacts/fork — fork this workspace's repo // POST /workspaces/:id/artifacts/token — mint a short-lived git credential // // Configuration (env vars, loaded once at platform startup): // // CF_ARTIFACTS_API_TOKEN — Cloudflare API token with Artifacts write permissions // CF_ARTIFACTS_NAMESPACE — Cloudflare Artifacts namespace name // // When either env var is absent the handler returns 503 with a clear message so // callers know the feature is not yet configured (private beta onboarding). // // See: https://developers.cloudflare.com/artifacts/ import ( "database/sql" "log" "net/http" "os" "regexp" "strings" "time" "github.com/Molecule-AI/molecule-monorepo/platform/internal/artifacts" "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" "github.com/gin-gonic/gin" ) // repoNameRE validates CF Artifacts repo names: start with alphanumeric, // then up to 62 alphanumeric/hyphen/underscore chars (63 total max). var repoNameRE = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$`) // cfErrMessage returns a safe error message for CF API errors. // For CF 5xx errors (or non-CF errors), returns a generic "upstream service error" // to avoid leaking internal CF error details to clients. func cfErrMessage(err error) string { apiErr, ok := err.(*artifacts.APIError) if !ok || apiErr.StatusCode >= 500 { return "upstream service error" } return apiErr.Message } // ArtifactsHandler holds a pre-built CF Artifacts client. // The client is nil when CF_ARTIFACTS_API_TOKEN / CF_ARTIFACTS_NAMESPACE are unset. type ArtifactsHandler struct { client *artifacts.Client namespace string } // NewArtifactsHandler reads CF_ARTIFACTS_API_TOKEN and CF_ARTIFACTS_NAMESPACE // from the environment and builds the client. If either is absent the handler // still registers — every method simply returns 503. func NewArtifactsHandler() *ArtifactsHandler { token := os.Getenv("CF_ARTIFACTS_API_TOKEN") ns := os.Getenv("CF_ARTIFACTS_NAMESPACE") if token == "" || ns == "" { log.Printf("artifacts: CF_ARTIFACTS_API_TOKEN or CF_ARTIFACTS_NAMESPACE not set — demo endpoints will return 503") return &ArtifactsHandler{} } return &ArtifactsHandler{ client: artifacts.New(token, ns), namespace: ns, } } // newArtifactsHandlerWithClient is the injectable constructor used in tests. func newArtifactsHandlerWithClient(client *artifacts.Client, namespace string) *ArtifactsHandler { return &ArtifactsHandler{client: client, namespace: namespace} } // configured returns false (and writes a 503) when the CF client is missing. func (h *ArtifactsHandler) configured(c *gin.Context) bool { if h.client == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Cloudflare Artifacts not configured — set CF_ARTIFACTS_API_TOKEN and CF_ARTIFACTS_NAMESPACE", }) return false } return true } // ---- POST /workspaces/:id/artifacts ------------------------------------ // createArtifactsRepoRequest is the body for attaching/creating a CF Artifacts repo. type createArtifactsRepoRequest struct { // Name is the desired CF repo name. Defaults to "molecule-ws-" when empty. Name string `json:"name"` // Description is an optional label stored in CF and in the local DB. Description string `json:"description"` // ImportURL, when non-empty, bootstraps the repo from an existing Git URL // (e.g. "https://github.com/org/repo.git") instead of creating an empty repo. ImportURL string `json:"import_url"` // ImportBranch restricts the import to a single branch (only used with ImportURL). ImportBranch string `json:"import_branch"` // ImportDepth sets a shallow-clone depth for the import (0 = full history). ImportDepth int `json:"import_depth"` // ReadOnly marks the new repo as fetch/clone-only. ReadOnly bool `json:"read_only"` } // workspaceArtifactRow is the DB row shape returned by queries. type workspaceArtifactRow struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` CFRepoName string `json:"cf_repo_name"` CFNamespace string `json:"cf_namespace"` RemoteURL string `json:"remote_url,omitempty"` Description string `json:"description,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Create handles POST /workspaces/:id/artifacts. // Creates or imports a Cloudflare Artifacts repo and links it to the workspace. // Returns 409 if a repo is already linked. func (h *ArtifactsHandler) Create(c *gin.Context) { if !h.configured(c) { return } workspaceID := c.Param("id") ctx := c.Request.Context() // Reject if already linked. var exists bool db.DB.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM workspace_artifacts WHERE workspace_id = $1)`, workspaceID, ).Scan(&exists) if exists { c.JSON(http.StatusConflict, gin.H{"error": "workspace already has a linked Artifacts repo — delete it first"}) return } var req createArtifactsRepoRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } // Default repo name: "molecule-ws-" (truncated at 63 chars). repoName := req.Name if repoName == "" { repoName = "molecule-ws-" + workspaceID if len(repoName) > 63 { repoName = repoName[:63] } } // Validate explicit repo names; auto-generated names are always safe. if req.Name != "" && !repoNameRE.MatchString(req.Name) { c.JSON(http.StatusBadRequest, gin.H{"error": "repo name must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$"}) return } var ( repo *artifacts.Repo err error ) if req.ImportURL != "" { // Fix 1: require HTTPS for import URLs to prevent SSRF via non-HTTPS schemes. if !strings.HasPrefix(req.ImportURL, "https://") { c.JSON(http.StatusBadRequest, gin.H{"error": "import_url must use https://"}) return } repo, err = h.client.ImportRepo(ctx, repoName, artifacts.ImportRepoRequest{ URL: req.ImportURL, Branch: req.ImportBranch, Depth: req.ImportDepth, ReadOnly: req.ReadOnly, }) } else { repo, err = h.client.CreateRepo(ctx, artifacts.CreateRepoRequest{ Name: repoName, Description: req.Description, ReadOnly: req.ReadOnly, }) } if err != nil { log.Printf("artifacts: CreateRepo/ImportRepo failed for workspace %s: %v", workspaceID, err) c.JSON(cfErrToHTTP(err), gin.H{"error": cfErrMessage(err)}) return } // Strip the embedded credential from the URL before persisting. remoteURL := stripCredentials(repo.RemoteURL) var row workspaceArtifactRow err = db.DB.QueryRowContext(ctx, ` INSERT INTO workspace_artifacts (workspace_id, cf_repo_name, cf_namespace, remote_url, description) VALUES ($1, $2, $3, $4, $5) RETURNING id, workspace_id, cf_repo_name, cf_namespace, remote_url, description, created_at, updated_at `, workspaceID, repo.Name, h.namespace, remoteURL, req.Description).Scan( &row.ID, &row.WorkspaceID, &row.CFRepoName, &row.CFNamespace, &row.RemoteURL, &row.Description, &row.CreatedAt, &row.UpdatedAt, ) if err != nil { log.Printf("artifacts: DB insert failed for workspace %s: %v", workspaceID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to persist artifact link"}) return } c.JSON(http.StatusCreated, row) } // ---- GET /workspaces/:id/artifacts ------------------------------------- // Get handles GET /workspaces/:id/artifacts. // Returns the linked Cloudflare Artifacts repo info from local DB and CF API. func (h *ArtifactsHandler) Get(c *gin.Context) { if !h.configured(c) { return } workspaceID := c.Param("id") ctx := c.Request.Context() var row workspaceArtifactRow err := db.DB.QueryRowContext(ctx, ` SELECT id, workspace_id, cf_repo_name, cf_namespace, remote_url, description, created_at, updated_at FROM workspace_artifacts WHERE workspace_id = $1 `, workspaceID).Scan( &row.ID, &row.WorkspaceID, &row.CFRepoName, &row.CFNamespace, &row.RemoteURL, &row.Description, &row.CreatedAt, &row.UpdatedAt, ) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "no Artifacts repo linked to this workspace"}) return } if err != nil { log.Printf("artifacts: DB query failed for workspace %s: %v", workspaceID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } // Augment with live info from CF API (remote URL may have changed, etc.). cfRepo, err := h.client.GetRepo(ctx, row.CFRepoName) if err != nil { // CF API unavailable — return cached DB row with a warning. log.Printf("artifacts: GetRepo from CF failed for %s: %v", row.CFRepoName, err) c.JSON(http.StatusOK, gin.H{ "artifact": row, "cf_status": "unavailable", "cf_error": cfErrMessage(err), }) return } c.JSON(http.StatusOK, gin.H{ "artifact": row, "cf_repo": cfRepo, "cf_status": "ok", }) } // ---- POST /workspaces/:id/artifacts/fork ------------------------------- // forkArtifactsRepoRequest is the body for forking a workspace's repo. type forkArtifactsRepoRequest struct { // Name is the desired name of the forked repo. Required. Name string `json:"name" binding:"required"` // Description is an optional label for the fork. Description string `json:"description"` // ReadOnly marks the fork as fetch/clone-only. ReadOnly bool `json:"read_only"` // DefaultBranchOnly limits the fork to the default branch. DefaultBranchOnly bool `json:"default_branch_only"` } // Fork handles POST /workspaces/:id/artifacts/fork. // Creates an isolated copy of the workspace's primary Artifacts repo in CF. // The fork is not recorded in the local DB — it is owned by the caller. func (h *ArtifactsHandler) Fork(c *gin.Context) { if !h.configured(c) { return } workspaceID := c.Param("id") ctx := c.Request.Context() // Look up the source repo name. var cfRepoName string err := db.DB.QueryRowContext(ctx, `SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id = $1`, workspaceID, ).Scan(&cfRepoName) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "no Artifacts repo linked to this workspace"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } var req forkArtifactsRepoRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } if req.Name != "" && !repoNameRE.MatchString(req.Name) { c.JSON(http.StatusBadRequest, gin.H{"error": "repo name must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$"}) return } result, err := h.client.ForkRepo(ctx, cfRepoName, artifacts.ForkRepoRequest{ Name: req.Name, Description: req.Description, ReadOnly: req.ReadOnly, DefaultBranchOnly: req.DefaultBranchOnly, }) if err != nil { log.Printf("artifacts: ForkRepo failed for workspace %s: %v", workspaceID, err) c.JSON(cfErrToHTTP(err), gin.H{"error": cfErrMessage(err)}) return } c.JSON(http.StatusCreated, gin.H{ "fork": result.Repo, "object_count": result.ObjectCount, "remote_url": stripCredentials(result.Repo.RemoteURL), }) } // ---- POST /workspaces/:id/artifacts/token ------------------------------ // artifactsTokenRequest is the body for minting a git credential. type artifactsTokenRequest struct { // Scope is "read" or "write". Defaults to "write". Scope string `json:"scope"` // TTL is the credential lifetime in seconds. Defaults to 3600 (1h). TTL int `json:"ttl"` } // Token handles POST /workspaces/:id/artifacts/token. // Returns a short-lived Git credential for the workspace's linked repo. // The plaintext token value must be saved by the caller — it is not stored. func (h *ArtifactsHandler) Token(c *gin.Context) { if !h.configured(c) { return } workspaceID := c.Param("id") ctx := c.Request.Context() // Look up the linked CF repo name. var cfRepoName string err := db.DB.QueryRowContext(ctx, `SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id = $1`, workspaceID, ).Scan(&cfRepoName) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "no Artifacts repo linked to this workspace"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } var req artifactsTokenRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } scope := req.Scope if scope == "" { scope = "write" } if scope != "read" && scope != "write" { c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be \"read\" or \"write\""}) return } ttl := req.TTL if ttl <= 0 { ttl = 3600 } const maxTTL = 86400 * 7 // 7 days if ttl > maxTTL { ttl = maxTTL } tok, err := h.client.CreateToken(ctx, artifacts.CreateTokenRequest{ Repo: cfRepoName, Scope: scope, TTL: ttl, }) if err != nil { log.Printf("artifacts: CreateToken failed for workspace %s: %v", workspaceID, err) c.JSON(cfErrToHTTP(err), gin.H{"error": cfErrMessage(err)}) return } // Build the authenticated git remote URL inline so callers can use it // directly: git clone cloneURL := buildCloneURL(cfRepoName, tok.Token, h.namespace) c.JSON(http.StatusCreated, gin.H{ "token_id": tok.ID, "token": tok.Token, "scope": tok.Scope, "expires_at": tok.ExpiresAt, "clone_url": cloneURL, "message": "Save this token — it cannot be retrieved again.", }) } // ---- helpers ------------------------------------------------------------- // cfErrToHTTP converts a CF API error to an appropriate HTTP status code. // Passes through 4xx, maps everything else to 502 (bad gateway — upstream CF). func cfErrToHTTP(err error) int { apiErr, ok := err.(*artifacts.APIError) if !ok { return http.StatusBadGateway } if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { return apiErr.StatusCode } return http.StatusBadGateway } // stripCredentials removes "x:@" from an authenticated git remote URL // so we never persist credentials in the database. // e.g. "https://x:tok@hash.artifacts.cloudflare.net/…" → "https://hash.artifacts.cloudflare.net/…" func stripCredentials(remoteURL string) string { if i := strings.Index(remoteURL, "@"); i != -1 { scheme := "https://" if strings.HasPrefix(remoteURL, "http://") { scheme = "http://" } return scheme + remoteURL[i+1:] } return remoteURL } // buildCloneURL constructs an authenticated clone URL from the CF token. // Format: https://x:@.artifacts.cloudflare.net/git/repo-.git // When we only have the repo name (not the full hashed host), we use a stable // URL pattern that the CF git endpoint resolves. func buildCloneURL(repoName, token, _ string) string { // The CF git endpoint is the remote_url stored in the DB (minus the // credential prefix). We reconstruct the authenticated form here. // In production the remote URL is returned by CreateRepo/GetRepo; // this fallback covers cases where the DB row predates that field. return "https://x:" + token + "@artifacts.cloudflare.net/git/" + repoName + ".git" }