package handler import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) // ProjectResourceResponse is the JSON shape returned by the project resource API. type ProjectResourceResponse struct { ID string `json:"id"` ProjectID string `json:"project_id"` WorkspaceID string `json:"workspace_id"` ResourceType string `json:"resource_type"` ResourceRef json.RawMessage `json:"resource_ref"` Label *string `json:"label"` Position int32 `json:"position"` CreatedAt string `json:"created_at"` CreatedBy *string `json:"created_by"` } func projectResourceToResponse(r db.ProjectResource) ProjectResourceResponse { ref := json.RawMessage(r.ResourceRef) if len(ref) == 0 { ref = json.RawMessage("{}") } return ProjectResourceResponse{ ID: uuidToString(r.ID), ProjectID: uuidToString(r.ProjectID), WorkspaceID: uuidToString(r.WorkspaceID), ResourceType: r.ResourceType, ResourceRef: ref, Label: textToPtr(r.Label), Position: r.Position, CreatedAt: timestampToString(r.CreatedAt), CreatedBy: uuidToPtr(r.CreatedBy), } } // CreateProjectResourceRequest is the body for POST /api/projects/{id}/resources. type CreateProjectResourceRequest struct { ResourceType string `json:"resource_type"` ResourceRef json.RawMessage `json:"resource_ref"` Label *string `json:"label"` Position *int32 `json:"position"` } // UpdateProjectResourceRequest is the body for PUT /api/projects/{id}/resources/{resourceId}. // resource_type cannot change after creation — pick a new type by deleting and // re-adding. Every field is optional; omitted fields keep their current value. type UpdateProjectResourceRequest struct { ResourceRef json.RawMessage `json:"resource_ref"` Label *string `json:"label"` Position *int32 `json:"position"` } // validateAndNormalizeResourceRef checks the payload for a known resource_type. // New types are added here without schema migration; unknown types are rejected // at the API boundary so a typo can't slip through and produce a resource the // daemon/UI doesn't understand. func validateAndNormalizeResourceRef(resourceType string, ref json.RawMessage) (json.RawMessage, error) { if len(ref) == 0 { return nil, errors.New("resource_ref is required") } switch resourceType { case "github_repo": return validateGithubRepoRef(ref) case "local_directory": return validateLocalDirectoryRef(ref) default: return nil, fmt.Errorf("unknown resource_type %q", resourceType) } } type githubRepoRef struct { URL string `json:"url"` DefaultBranchHint string `json:"default_branch_hint,omitempty"` } func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) { var payload githubRepoRef if err := json.Unmarshal(ref, &payload); err != nil { return nil, fmt.Errorf("invalid github_repo payload: %w", err) } payload.URL = strings.TrimSpace(payload.URL) if payload.URL == "" { return nil, errors.New("github_repo: url is required") } if !isValidGitRepoURL(payload.URL) { return nil, errors.New("github_repo: url must be a valid http(s) or ssh git URL") } payload.DefaultBranchHint = strings.TrimSpace(payload.DefaultBranchHint) out, err := json.Marshal(payload) if err != nil { return nil, err } return out, nil } // localDirectoryRef is the JSONB shape stored for resource_type=local_directory. // It pins a project to an existing directory on a specific user machine, so // agent tasks run in-place rather than in an isolated git worktree. The // daemon_id scopes the path to one daemon registration — the same string path // on a different machine is a different resource. The optional label is a // human-readable hint used by the UI; the row-level project_resource.label // column remains the generic column for any resource type. type localDirectoryRef struct { LocalPath string `json:"local_path"` DaemonID string `json:"daemon_id"` Label string `json:"label,omitempty"` } func validateLocalDirectoryRef(ref json.RawMessage) (json.RawMessage, error) { var payload localDirectoryRef if err := json.Unmarshal(ref, &payload); err != nil { return nil, fmt.Errorf("invalid local_directory payload: %w", err) } payload.LocalPath = strings.TrimSpace(payload.LocalPath) if payload.LocalPath == "" { return nil, errors.New("local_directory: local_path is required") } if !isAbsoluteLocalPath(payload.LocalPath) { return nil, errors.New("local_directory: local_path must be an absolute path") } payload.DaemonID = strings.TrimSpace(payload.DaemonID) if payload.DaemonID == "" { return nil, errors.New("local_directory: daemon_id is required") } payload.Label = strings.TrimSpace(payload.Label) out, err := json.Marshal(payload) if err != nil { return nil, err } return out, nil } // isAbsoluteLocalPath checks the path looks absolute on either POSIX or // Windows daemons. The server can't know which OS the daemon runs on, so we // accept the union: a leading "/" (POSIX), a UNC prefix "\\", or a drive // letter like "C:\" or "C:/". The daemon still verifies existence at run // time — this is a typo guard, not a filesystem check. func isAbsoluteLocalPath(s string) bool { if s == "" { return false } if s[0] == '/' { return true } if strings.HasPrefix(s, `\\`) { return true } if len(s) >= 3 && isDriveLetter(s[0]) && s[1] == ':' && (s[2] == '\\' || s[2] == '/') { return true } return false } func isDriveLetter(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') } // isValidGitRepoURL accepts the three forms a user can paste from GitHub's // "Code" menu: https://, ssh:// (with explicit scheme), and the scp-like // shorthand `git@host:owner/repo.git`. The check is intentionally lax — we are // guarding against pasted garbage like "not-a-url", not enforcing a strict // grammar — because the actual fetch happens client-side via `git clone` and // the user gets a clearer error from git than from us. func isValidGitRepoURL(s string) bool { if u, err := url.Parse(s); err == nil && u.Host != "" { switch u.Scheme { case "http", "https", "ssh", "git": return true } } // scp-like ssh shorthand: [user@]host:path with a non-empty host and path, // and no spaces. Reject anything that looks like a URL with a scheme // (those should go through url.Parse above). if strings.Contains(s, " ") || strings.Contains(s, "://") { return false } colon := strings.Index(s, ":") if colon <= 0 || colon == len(s)-1 { return false } // In scp-like ssh shorthand `[user@]host:path`, `@` is only meaningful // as a user separator before the first ':'. If '@' appears at or after // the colon it is not the user separator — reject as malformed rather // than guess (and avoid a slice-bounds panic from blindly slicing). at := strings.Index(s, "@") if at >= colon { return false } hostStart := 0 if at >= 0 { hostStart = at + 1 } host := s[hostStart:colon] path := s[colon+1:] if host == "" || path == "" { return false } return true } // loadProjectForResource resolves the project, enforces workspace ownership, // and returns its DB row. Used by all project_resource handlers. func (h *Handler) loadProjectForResource(w http.ResponseWriter, r *http.Request, projectIDParam string) (db.Project, bool) { projectUUID, ok := parseUUIDOrBadRequest(w, projectIDParam, "project id") if !ok { return db.Project{}, false } wsUUID, ok := parseUUIDOrBadRequest(w, h.resolveWorkspaceID(r), "workspace id") if !ok { return db.Project{}, false } project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ ID: projectUUID, WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "project not found") return db.Project{}, false } return project, true } // ListProjectResources returns the resources attached to a project. func (h *Handler) ListProjectResources(w http.ResponseWriter, r *http.Request) { project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id")) if !ok { return } resources, err := h.Queries.ListProjectResources(r.Context(), project.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list project resources") return } resp := make([]ProjectResourceResponse, len(resources)) for i, res := range resources { resp[i] = projectResourceToResponse(res) } writeJSON(w, http.StatusOK, map[string]any{"resources": resp, "total": len(resp)}) } // CreateProjectResource attaches a new resource to a project. func (h *Handler) CreateProjectResource(w http.ResponseWriter, r *http.Request) { project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id")) if !ok { return } userID, ok := requireUserID(w, r) if !ok { return } var req CreateProjectResourceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } req.ResourceType = strings.TrimSpace(req.ResourceType) if req.ResourceType == "" { writeError(w, http.StatusBadRequest, "resource_type is required") return } normalizedRef, err := validateAndNormalizeResourceRef(req.ResourceType, req.ResourceRef) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if conflict, err := h.findLocalDirectoryConflict(r.Context(), project.ID, req.ResourceType, normalizedRef, pgtype.UUID{}); err != nil { writeError(w, http.StatusInternalServerError, "failed to check existing resources") return } else if conflict { writeError(w, http.StatusConflict, "this daemon already has a local_directory attached to the project; remove it before adding another") return } var label pgtype.Text if req.Label != nil && strings.TrimSpace(*req.Label) != "" { label = pgtype.Text{String: strings.TrimSpace(*req.Label), Valid: true} } var position int32 if req.Position != nil { position = *req.Position } else { // Append after existing resources. count, _ := h.Queries.CountProjectResources(r.Context(), project.ID) position = int32(count) } creator, _ := h.parseUserUUIDOrZero(userID) resource, err := h.Queries.CreateProjectResource(r.Context(), db.CreateProjectResourceParams{ ProjectID: project.ID, WorkspaceID: project.WorkspaceID, ResourceType: req.ResourceType, ResourceRef: normalizedRef, Label: label, Position: position, CreatedBy: creator, }) if err != nil { if isUniqueViolation(err) { writeError(w, http.StatusConflict, "this resource is already attached to the project") return } writeError(w, http.StatusInternalServerError, "failed to create project resource") return } resp := projectResourceToResponse(resource) h.publish( protocol.EventProjectResourceCreated, uuidToString(project.WorkspaceID), "member", userID, map[string]any{"resource": resp, "project_id": uuidToString(project.ID)}, ) writeJSON(w, http.StatusCreated, resp) } // UpdateProjectResource edits an existing resource's ref/label/position. // resource_type is immutable — re-pointing a resource at a different type is // almost always a different conceptual entity, so the caller should delete and // re-add instead. Omitted fields keep their current value, including the // `label` JSON null vs. missing distinction (missing = keep, explicit "" = // clear). func (h *Handler) UpdateProjectResource(w http.ResponseWriter, r *http.Request) { project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id")) if !ok { return } resourceUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "resourceId"), "resource id") if !ok { return } userID, ok := requireUserID(w, r) if !ok { return } existing, err := h.Queries.GetProjectResourceInWorkspace(r.Context(), db.GetProjectResourceInWorkspaceParams{ ID: resourceUUID, WorkspaceID: project.WorkspaceID, }) if err != nil { writeError(w, http.StatusNotFound, "project resource not found") return } if uuidToString(existing.ProjectID) != uuidToString(project.ID) { writeError(w, http.StatusNotFound, "project resource not found") return } // Decode into a raw map first so we can tell "field omitted" from // "field present with zero value" — the label clear case in particular // relies on this distinction. var raw map[string]json.RawMessage if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } nextRef := json.RawMessage(existing.ResourceRef) if rawRef, ok := raw["resource_ref"]; ok { normalized, err := validateAndNormalizeResourceRef(existing.ResourceType, rawRef) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } nextRef = normalized } if conflict, err := h.findLocalDirectoryConflict(r.Context(), project.ID, existing.ResourceType, nextRef, existing.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to check existing resources") return } else if conflict { writeError(w, http.StatusConflict, "another local_directory on this daemon is already attached to the project") return } nextLabel := existing.Label if rawLabel, ok := raw["label"]; ok { var labelStr *string if err := json.Unmarshal(rawLabel, &labelStr); err != nil { writeError(w, http.StatusBadRequest, "label must be a string or null") return } if labelStr == nil || strings.TrimSpace(*labelStr) == "" { nextLabel = pgtype.Text{} } else { nextLabel = pgtype.Text{String: strings.TrimSpace(*labelStr), Valid: true} } } nextPosition := existing.Position if rawPos, ok := raw["position"]; ok { var pos *int32 if err := json.Unmarshal(rawPos, &pos); err != nil { writeError(w, http.StatusBadRequest, "position must be an integer") return } if pos != nil { nextPosition = *pos } } updated, err := h.Queries.UpdateProjectResource(r.Context(), db.UpdateProjectResourceParams{ ID: existing.ID, ResourceRef: nextRef, Label: nextLabel, Position: nextPosition, }) if err != nil { if isUniqueViolation(err) { writeError(w, http.StatusConflict, "this resource is already attached to the project") return } writeError(w, http.StatusInternalServerError, "failed to update project resource") return } resp := projectResourceToResponse(updated) h.publish( protocol.EventProjectResourceUpdated, uuidToString(project.WorkspaceID), "member", userID, map[string]any{"resource": resp, "project_id": uuidToString(project.ID)}, ) writeJSON(w, http.StatusOK, resp) } // findLocalDirectoryConflict enforces "at most one local_directory resource // per (project, daemon)". The daemon picks the first matching daemon_id row // out of a task's resources (findLocalDirectoryAssignment), so letting a // project carry two rows for the same daemon would mean the agent silently // writes into whichever happens to come back first — a safety hazard for a // feature that operates directly on the user's real working directory. // // The DB-level UNIQUE(project_id, resource_type, resource_ref) constraint // alone is not enough here: it only fires on full ref-JSON equality, so a // different local_path or even a typoed label on the same daemon would slip // through. We do the daemon-scoped check here in application code instead. // // `excludeID` lets the update path ignore the row being edited. func (h *Handler) findLocalDirectoryConflict(ctx context.Context, projectID pgtype.UUID, resourceType string, normalizedRef json.RawMessage, excludeID pgtype.UUID) (bool, error) { if resourceType != "local_directory" { return false, nil } var incoming localDirectoryRef if err := json.Unmarshal(normalizedRef, &incoming); err != nil { return false, err } rows, err := h.Queries.ListProjectResources(ctx, projectID) if err != nil { return false, err } for _, row := range rows { if row.ResourceType != "local_directory" { continue } if excludeID.Valid && uuidToString(row.ID) == uuidToString(excludeID) { continue } var existing localDirectoryRef if err := json.Unmarshal(row.ResourceRef, &existing); err != nil { continue } // Daemon-scoped uniqueness: one local_directory per daemon per // project. Different daemons can each carry one row (one per // user device); the daemon-side resolver routes each daemon to // its own assignment by daemon_id. if existing.DaemonID == incoming.DaemonID { return true, nil } } return false, nil } // DeleteProjectResource removes a resource from a project. func (h *Handler) DeleteProjectResource(w http.ResponseWriter, r *http.Request) { project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id")) if !ok { return } resourceUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "resourceId"), "resource id") if !ok { return } userID, ok := requireUserID(w, r) if !ok { return } resource, err := h.Queries.GetProjectResourceInWorkspace(r.Context(), db.GetProjectResourceInWorkspaceParams{ ID: resourceUUID, WorkspaceID: project.WorkspaceID, }) if err != nil { writeError(w, http.StatusNotFound, "project resource not found") return } if uuidToString(resource.ProjectID) != uuidToString(project.ID) { writeError(w, http.StatusNotFound, "project resource not found") return } if err := h.Queries.DeleteProjectResource(r.Context(), resource.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete project resource") return } h.publish( protocol.EventProjectResourceDeleted, uuidToString(project.WorkspaceID), "member", userID, map[string]any{ "project_id": uuidToString(project.ID), "resource_id": uuidToString(resource.ID), }, ) w.WriteHeader(http.StatusNoContent) } // parseUserUUIDOrZero converts a user ID string to a pgtype.UUID, returning a // zero value on any error so the caller can store NULL for created_by when the // authenticated principal is not a workspace member (e.g. internal-server use). func (h *Handler) parseUserUUIDOrZero(userID string) (pgtype.UUID, bool) { if userID == "" { return pgtype.UUID{}, false } u, err := parseUUIDLoose(userID) if err != nil { return pgtype.UUID{}, false } return u, true } // parseUUIDLoose mirrors util.ParseUUID but lives here to avoid pulling util // into a tiny one-off helper. Keep the body minimal. func parseUUIDLoose(s string) (pgtype.UUID, error) { var u pgtype.UUID if err := u.Scan(s); err != nil { return pgtype.UUID{}, err } return u, nil } // listProjectResourcesForProject is a small helper used by the daemon claim // handler to attach project resources to outgoing tasks. func (h *Handler) listProjectResourcesForProject(ctx context.Context, projectID pgtype.UUID) []db.ProjectResource { if !projectID.Valid { return nil } rows, err := h.Queries.ListProjectResources(ctx, projectID) if err != nil { return nil } return rows }